A high-fidelity, animated student learning dashboard built for the Frontend Intern Challenge. Designed around a premium "Obsidian OS" aesthetic — deep dark backgrounds, electric cyan accents, and buttery-smooth Framer Motion animations throughout.
Live Demo: [https://next-gen-learning-dashboard-sage.vercel.app/]
Repository: [https://github.com/rishitbisoyi/next-gen-learning-dashboard]
| Layer | Technology |
|---|---|
| Framework | Next.js 15 (App Router) |
| Database | Supabase (PostgreSQL) |
| Styling | Tailwind CSS v4 |
| Animations | Framer Motion |
| Icons | Lucide React |
| Fonts | Syne (display) + Space Mono (mono) via next/font/google |
| Deployment | Vercel |
The data-fetching layer is kept entirely on the server to avoid exposing Supabase credentials to the browser and to eliminate client-side loading waterfalls.
app/
└── page.tsx ← Server Component — fetches courses via getCourses()
├── Sidebar.tsx ← Client Component ("use client") — interactive nav state
├── BentoGrid.tsx ← Client Component — Framer Motion entrance animations
│ ├── HeroTile.tsx ← Client Component — streak badge animation
│ ├── CourseCard.tsx ← Client Component — hover spring + progress animation
│ └── ActivityTile.tsx ← Client Component — staggered grid reveal
└── MobileNav.tsx ← Client Component — active tab state
Rule of thumb used: A component becomes "use client" only when it needs useState, useEffect, or Framer Motion (motion.*). Everything else stays a Server Component.
lib/getCourses.ts runs exclusively on the server:
const { data, error } = await supabase.from("courses").select("*");- Uses
@supabase/supabase-jswithNEXT_PUBLIC_SUPABASE_URLandNEXT_PUBLIC_SUPABASE_ANON_KEYenvironment variables - Throws on error so Next.js catches it and renders
app/error.tsx - The result is passed as a prop into
<BentoGrid courses={courses} />— no client-side fetching, nouseEffect
app/loading.tsx— automatically shown by Next.js during server-side data fetching. Uses pulsing.skeletonCSS classes with a custompulse-darkkeyframe animation.app/error.tsx— catches thrown errors fromgetCourses(). Renders a styled error card with a retry button that callsreset().
create table courses (
id uuid primary key default gen_random_uuid(),
title text not null,
progress integer not null,
icon_name text not null,
created_at timestamptz default now()
);insert into courses (title, progress, icon_name) values
('Advanced React Patterns', 75, 'Code'),
('Next.js Mastery', 55, 'Rocket'),
('Data Structures', 90, 'Brain'),
('TypeScript Pro', 40, 'FileCode');Code · Rocket · Brain · FileCode · Cpu · Globe · Database · Layers
Each maps to a unique accent color in CourseCard.tsx.
Copy .env.example to .env.local and fill in your Supabase project values:
cp .env.example .env.localNEXT_PUBLIC_SUPABASE_URL=https://your-project-id.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key-here
⚠️ Never commit.env.local. It is listed in.gitignore. Only.env.example(with empty values) is committed.
All animations are handled by Framer Motion with strict adherence to performance best practices.
Every animation uses only transform and opacity — properties that are handled by the GPU compositor and never trigger browser repaints or layout recalculations:
// ✅ Correct — compositor-only properties
whileHover={{ scale: 1.025, y: -4 }}
// ❌ Never used — triggers layout
whileHover={{ width: "110%", marginTop: -4 }}All interactive animations use spring physics for a natural, non-linear feel:
transition={{ type: "spring", stiffness: 300, damping: 20 }}| Use case | Stiffness | Damping | Feel |
|---|---|---|---|
| Card hover | 300 | 20 | Snappy, energetic |
| Entrance stagger | 260 | 24 | Smooth, settled |
| Sidebar nav highlight | 380 | 30 | Fast, precise |
Tiles don't appear all at once. BentoGrid uses staggerChildren: 0.12 so each tile enters sequentially, sliding up 24px while fading in:
const containerVariants = {
hidden: {},
show: { transition: { staggerChildren: 0.12, delayChildren: 0.1 } },
};
const itemVariants = {
hidden: { opacity: 0, y: 24, scale: 0.97 },
show: { opacity: 1, y: 0, scale: 1 },
};The active sidebar highlight and mobile nav indicator use Framer Motion's layoutId for automatic FLIP animation — the background pill animates smoothly between items without any manual coordinate calculation:
{isActive && (
<motion.div layoutId="active-nav" className="absolute inset-0 rounded-xl"
style={{ background: "var(--accent-dim)" }}
transition={{ type: "spring", stiffness: 380, damping: 30 }} />
)}Each course card's progress bar animates from 0% to the fetched value on mount, with a shimmer sweep effect after:
<motion.div
initial={{ width: 0 }}
animate={{ width: `${course.progress}%` }}
transition={{ duration: 1.2, ease: [0.22, 1, 0.36, 1], delay: 0.3 }}
/>| Breakpoint | Sidebar | Grid |
|---|---|---|
Mobile < 768px |
Hidden; replaced by bottom nav bar | Single column |
Tablet 768–1024px |
Icon-only (72px wide) | 2-column grid |
Desktop > 1024px |
Full sidebar with labels (240px) | 3-column bento grid |
The entire UI is driven by CSS custom properties defined in globals.css:
--bg-base: #080c10 /* Page background */
--bg-surface: #0d1117 /* Sidebar */
--bg-card: rgba(16,22,30,0.85) /* Glass cards */
--accent: #00e5cc /* Cyan — primary accent */
--accent-dim: rgba(0,229,204,0.12) /* Tinted backgrounds */
--border-subtle: rgba(255,255,255,0.06)
--border-glow: rgba(0,229,204,0.35) /* Hover border */Course cards each get a unique accent color derived from their icon_name field, creating visual variety without additional database fields.
# Install dependencies
npm install
# Add environment variables
cp .env.example .env.local
# Fill in NEXT_PUBLIC_SUPABASE_URL and NEXT_PUBLIC_SUPABASE_ANON_KEY
# Start development server
npm run devOpen http://localhost:3000.
Font loading with Tailwind v4 + Turbopack: CSS @import url() for Google Fonts caused a PostCSS parsing error because Turbopack injects @font-face rules above the stylesheet, making the @import appear out of order. Solved by loading fonts exclusively via next/font/google in layout.tsx, which handles font injection at the framework level with no CSS import needed.
Server vs Client boundary: BentoGrid needs to be a Client Component for Framer Motion, but page.tsx fetches data on the server. The solution is to fetch in the Server Component and pass data as props — the standard RSC pattern. This keeps credentials server-side while giving the client full animation control.
Activity grid sizing: CSS aspect-square on grid cells causes unpredictable heights depending on container width. Fixed with explicit gridTemplateRows: "repeat(7, 11px)" so cells are always square regardless of viewport.