EdTech UI Design: Course Cards, Progress Bars and Video Players
Build EdTech UIs that actually convert — course cards, progress tracking, and video players done right in React with real code and zero boilerplate.
Why EdTech UI Is Harder Than It Looks
Most UI tutorials treat education platforms like they're just another CRUD app. They're not. You're dealing with learners who are already stressed — they're juggling jobs, families, deadlines — and the interface either makes them feel like they're making progress or it quietly destroys their motivation. That's a different design problem than an e-commerce checkout flow.
Honestly, the number of EdTech platforms I've seen ship in 2025 and 2026 with broken progress bars, misaligned video controls, and course cards that look copy-pasted from a Bootstrap template is depressing. The competition is brutal. Learners will bounce to Udemy or Coursera the second your UI feels like extra friction.
The three components that carry the most weight in any learning platform are course cards (first impression, conversion), progress bars (motivation, retention), and video players (the actual product). Get those three right and everything else is polish. This guide focuses on exactly that — with production-ready React code you can drop in today.
Quick aside: if you want a head start with pre-built premium components that already handle dark mode, accessibility, and animation, Empire UI has a library built specifically for dashboards and product UIs. Worth browsing before you reinvent the wheel.
Course Cards That Actually Convert
A course card has one job: make someone click. It needs to communicate the course title, progress state (enrolled vs new), difficulty, duration, and visual identity in roughly 280px of width. That's not much. Every pixel matters.
The biggest mistake teams make is cramming too much in. Four lines of description text, a tag cloud, a star rating, an author avatar, a price badge — all inside one card. It looks busy and nothing reads clearly. In practice, you want a single strong thumbnail, a bold title (max 2 lines, line-clamp-2), one metadata row (duration + level), and a clear CTA or progress indicator. That's it.
Here's a component that gets this balance right. It handles both the enrolled state (showing a progress bar) and the browse state (showing a price or "free" badge):
// CourseCard.tsx
import { BookOpen, Clock } from 'lucide-react';
interface CourseCardProps {
title: string;
thumbnail: string;
duration: string;
level: 'Beginner' | 'Intermediate' | 'Advanced';
progress?: number; // 0-100, undefined = not enrolled
price?: string; // undefined = free
instructor: string;
}
const levelColors = {
Beginner: 'bg-emerald-100 text-emerald-700',
Intermediate: 'bg-amber-100 text-amber-700',
Advanced: 'bg-red-100 text-red-700',
};
export function CourseCard({
title,
thumbnail,
duration,
level,
progress,
price,
instructor,
}: CourseCardProps) {
const enrolled = progress !== undefined;
return (
<div className="group w-full rounded-2xl overflow-hidden bg-white border border-gray-100 shadow-sm hover:shadow-md transition-shadow duration-200">
{/* Thumbnail */}
<div className="relative h-44 overflow-hidden">
<img
src={thumbnail}
alt={title}
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
/>
{!enrolled && (
<span className="absolute top-3 right-3 bg-white/90 backdrop-blur-sm text-gray-800 text-xs font-semibold px-2 py-1 rounded-full">
{price ?? 'Free'}
</span>
)}
</div>
{/* Body */}
<div className="p-4 space-y-3">
{/* Level badge */}
<span className={`inline-block text-xs font-medium px-2 py-0.5 rounded-full ${levelColors[level]}`}>
{level}
</span>
{/* Title */}
<h3 className="font-semibold text-gray-900 text-sm leading-snug line-clamp-2">
{title}
</h3>
{/* Meta row */}
<div className="flex items-center gap-3 text-xs text-gray-500">
<span className="flex items-center gap-1">
<Clock className="w-3.5 h-3.5" />
{duration}
</span>
<span className="flex items-center gap-1">
<BookOpen className="w-3.5 h-3.5" />
{instructor}
</span>
</div>
{/* Progress or CTA */}
{enrolled ? (
<div className="space-y-1">
<div className="flex justify-between text-xs text-gray-500">
<span>Progress</span>
<span>{progress}%</span>
</div>
<div className="h-1.5 bg-gray-100 rounded-full overflow-hidden">
<div
className="h-full bg-indigo-500 rounded-full transition-all duration-500"
style={{ width: `${progress}%` }}
/>
</div>
</div>
) : (
<button className="w-full mt-1 py-2 rounded-xl bg-indigo-600 hover:bg-indigo-700 text-white text-sm font-medium transition-colors">
Enroll Now
</button>
)}
</div>
</div>
);
}Worth noting: the group-hover:scale-105 on the thumbnail gives you a subtle zoom-in without JavaScript. Combined with the hover:shadow-md on the card itself, you get two layers of hover feedback that feel premium without costing performance.
Progress Bars That Actually Motivate
A thin gray progress bar at 23% doesn't motivate anyone. It just reminds them they have 77% left. The psychology here matters — learners need to feel momentum, not deficit. The way you communicate progress determines whether they come back tomorrow.
Three things fix this. First, animate the bar on mount with a 600ms ease-out so it visually "fills" toward the current value — starting at 0 and sweeping to 23% creates a sense of achievement rather than stagnation. Second, use milestone markers at 25%, 50%, 75% so the bar has intermediate goals. Third, change the color based on completion tier: gray for 0–30%, indigo for 31–70%, and a warm green for 71–100%. It reads as progression, not just a number.
// LearningProgress.tsx
'use client';
import { useEffect, useState } from 'react';
interface LearningProgressProps {
value: number; // 0-100
label?: string;
showMilestones?: boolean;
}
function getBarColor(value: number) {
if (value >= 71) return 'bg-emerald-500';
if (value >= 31) return 'bg-indigo-500';
return 'bg-gray-400';
}
export function LearningProgress({
value,
label,
showMilestones = true,
}: LearningProgressProps) {
const [displayed, setDisplayed] = useState(0);
useEffect(() => {
// Animate on mount — start from 0, ease to value
const raf = requestAnimationFrame(() => setDisplayed(value));
return () => cancelAnimationFrame(raf);
}, [value]);
return (
<div className="space-y-2">
{label && (
<div className="flex justify-between text-sm">
<span className="text-gray-600 font-medium">{label}</span>
<span className="text-gray-900 font-semibold tabular-nums">{value}%</span>
</div>
)}
<div className="relative h-3 bg-gray-100 rounded-full overflow-visible">
{/* Fill */}
<div
className={`absolute inset-y-0 left-0 rounded-full transition-all duration-[600ms] ease-out ${getBarColor(value)}`}
style={{ width: `${displayed}%` }}
/>
{/* Milestone markers */}
{showMilestones &&
[25, 50, 75].map((m) => (
<span
key={m}
className={`absolute top-0 -translate-x-1/2 w-0.5 h-full ${
value >= m ? 'bg-white/60' : 'bg-gray-300'
}`}
style={{ left: `${m}%` }}
/>
))}
</div>
{showMilestones && (
<div className="flex justify-between text-xs text-gray-400 px-0.5">
<span>0%</span>
<span>25%</span>
<span>50%</span>
<span>75%</span>
<span>100%</span>
</div>
)}
</div>
);
}One more thing — for a learner dashboard, consider adding a circular progress variant alongside the bar. A <svg> ring at 120px with a stroke-dashoffset animation works well for course completion summaries. The bar is for granular lesson progress, the ring is for the hero section. Different jobs, different shapes.
Look, you could also just reach for a library for this. But rolling your own with 60 lines of TSX means you control the color logic, the animation timing, and the accessible aria-valuenow attributes exactly. No library override hell.
Custom Video Players That Don't Suck
The native HTML5 <video> controls look different on every browser and OS. On Safari they look like 2014. On Firefox they look like a PDF reader. If your product is a video-first learning platform and you're shipping native controls, you're making your learners squint at ugly Chrome-Aqua buttons while trying to learn React hooks. That's a problem.
You don't need to write a full video player from scratch. The sweet spot is a thin wrapper around the <video> element that hides the native controls, exposes a custom control bar, and handles the most-used interactions: play/pause, seek (click on progress bar), mute, fullscreen, and playback speed. That's 90% of what learners actually use.
// VideoPlayer.tsx
'use client';
import { useRef, useState, useCallback } from 'react';
import { Play, Pause, Volume2, VolumeX, Maximize, Settings } from 'lucide-react';
const SPEEDS = [0.5, 0.75, 1, 1.25, 1.5, 2];
export function VideoPlayer({ src, poster }: { src: string; poster?: string }) {
const videoRef = useRef<HTMLVideoElement>(null);
const [playing, setPlaying] = useState(false);
const [progress, setProgress] = useState(0);
const [muted, setMuted] = useState(false);
const [speed, setSpeed] = useState(1);
const [showSpeeds, setShowSpeeds] = useState(false);
const toggle = useCallback(() => {
const v = videoRef.current!;
playing ? v.pause() : v.play();
setPlaying(!playing);
}, [playing]);
const handleTimeUpdate = () => {
const v = videoRef.current!;
setProgress((v.currentTime / v.duration) * 100);
};
const seek = (e: React.MouseEvent<HTMLDivElement>) => {
const v = videoRef.current!;
const rect = e.currentTarget.getBoundingClientRect();
const ratio = (e.clientX - rect.left) / rect.width;
v.currentTime = ratio * v.duration;
};
const changeSpeed = (s: number) => {
videoRef.current!.playbackRate = s;
setSpeed(s);
setShowSpeeds(false);
};
return (
<div className="group relative bg-black rounded-2xl overflow-hidden">
<video
ref={videoRef}
src={src}
poster={poster}
muted={muted}
onTimeUpdate={handleTimeUpdate}
onClick={toggle}
className="w-full aspect-video cursor-pointer"
/>
{/* Control bar — visible on hover */}
<div className="absolute bottom-0 left-0 right-0 p-3 bg-gradient-to-t from-black/80 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-200">
{/* Seek bar */}
<div
className="h-1 bg-white/30 rounded-full cursor-pointer mb-3 hover:h-2 transition-all"
onClick={seek}
>
<div
className="h-full bg-indigo-500 rounded-full"
style={{ width: `${progress}%` }}
/>
</div>
<div className="flex items-center gap-3">
<button onClick={toggle} className="text-white hover:text-indigo-300 transition-colors">
{playing ? <Pause className="w-5 h-5" /> : <Play className="w-5 h-5" />}
</button>
<button
onClick={() => setMuted(!muted)}
className="text-white hover:text-indigo-300 transition-colors"
>
{muted ? <VolumeX className="w-4 h-4" /> : <Volume2 className="w-4 h-4" />}
</button>
<div className="ml-auto relative">
<button
onClick={() => setShowSpeeds(!showSpeeds)}
className="flex items-center gap-1 text-xs text-white/80 hover:text-white"
>
<Settings className="w-3.5 h-3.5" />
{speed}x
</button>
{showSpeeds && (
<div className="absolute bottom-7 right-0 bg-gray-900 rounded-xl p-1 space-y-0.5">
{SPEEDS.map((s) => (
<button
key={s}
onClick={() => changeSpeed(s)}
className={`block w-full text-left px-3 py-1 text-xs rounded-lg ${
speed === s ? 'bg-indigo-600 text-white' : 'text-white/70 hover:bg-white/10'
}`}
>
{s}x
</button>
))}
</div>
)}
</div>
<button
onClick={() => videoRef.current?.requestFullscreen()}
className="text-white hover:text-indigo-300 transition-colors"
>
<Maximize className="w-4 h-4" />
</button>
</div>
</div>
</div>
);
}That seek bar does the hover:h-2 trick — it expands from 4px to 8px on hover, giving you a larger tap target without wasting permanent vertical space. Stolen from Spotify's desktop player. Works beautifully.
One thing this example leaves out intentionally: keyboard controls. You'd want onKeyDown on the wrapper div for Space (play/pause), ArrowLeft/ArrowRight (±10s seek), and M (mute). Add those before shipping. Also wire up aria-label on every button — your keyboard and screen-reader users will thank you.
Dashboard Layout: Putting It All Together
A learner dashboard needs to answer three questions at a glance: Where am I in my current course? What should I do next? How much have I done overall? If the layout doesn't surface those three answers in under two seconds, you've lost.
The layout that works best is a two-column split on desktop (≥1024px): a 2/3-width main column with the current lesson's video player and a 1/3-width sidebar with course progress, next-up lesson list, and achievement streak. On mobile, stack vertically — video first, progress second, sidebar collapses into an accordion.
// LearnerDashboard.tsx skeleton
export function LearnerDashboard() {
return (
<div className="min-h-screen bg-gray-50">
<div className="max-w-6xl mx-auto px-4 py-8">
<div className="grid grid-cols-1 lg:grid-cols-[1fr_340px] gap-6">
{/* Main: video + lesson info */}
<div className="space-y-6">
<VideoPlayer src="/lessons/intro.mp4" poster="/thumbnails/intro.jpg" />
<div className="bg-white rounded-2xl p-6 border border-gray-100">
<h1 className="text-xl font-bold text-gray-900 mb-1">
Introduction to TypeScript Generics
</h1>
<p className="text-sm text-gray-500">Module 3 · Lesson 4 of 12</p>
<LearningProgress value={33} label="Module progress" showMilestones />
</div>
</div>
{/* Sidebar: course overview + next up */}
<aside className="space-y-4">
<div className="bg-white rounded-2xl p-5 border border-gray-100">
<h2 className="font-semibold text-gray-800 mb-3">Course Overview</h2>
<LearningProgress value={22} label="Overall" />
</div>
{/* Next-up lesson list, streak widget, etc. */}
</aside>
</div>
</div>
</div>
);
}That 340px fixed sidebar width is deliberate. Below 340px the progress stats start wrapping awkwardly. Above 400px it dominates too much on a 1280px viewport. Most EdTech platforms land in the 320–360px range for the sidebar. Test on a 1280px screen as your baseline — that's where your desktop users predominantly land according to 2026 StatCounter data.
For the visual style, you've got options beyond plain white cards. A darker, more immersive dashboard can work well for video-heavy products — think something closer to the cyberpunk or aurora styles in Empire UI for that "premium course platform" feel. Light mode white cards work for B2B corporate training. Know your audience.
Styling Choices: What Works in EdTech
EdTech platforms have a broader demographic than, say, a developer tool. Your users might be 16-year-olds learning guitar or 55-year-olds getting their PMP certification. That range should make you cautious about trendy visual styles that sacrifice readability.
That said, "safe and boring" isn't the answer either. A flat, colorless dashboard communicates zero personality and learners won't feel attached to the product. The sweet spot is a clean base with intentional accent color — indigo is dominant in the EdTech space in 2026, but teal and violet are gaining ground — and selective use of depth on interactive elements like cards and video controls.
Glassmorphism actually works well for modal overlays and certificate displays in learning apps — the frosted glass effect feels celebratory and premium. Empire UI's glassmorphism components give you 12 pre-built variants. Just don't apply it to content-heavy areas where the background bleed reduces text contrast.
Worth noting: avoid purely dark-mode-first designs for EdTech. Eye strain matters when someone is 3 hours into a coding bootcamp at midnight, yes — but your onboarding flow and marketing pages will see a majority of light-mode users. Build light-first, add dark mode as a preference, not a default. The gradient generator is handy for generating your brand gradient tokens once and reusing them across both modes.
Performance and Accessibility Essentials
EdTech has stricter accessibility obligations than most product categories. US Section 508, WCAG 2.2 AA, and many enterprise procurement requirements all apply. Captions on video are not optional if you're selling to schools, universities, or corporate HR. Same for keyboard navigation through course content.
On performance: video is obviously the heavy asset, but course thumbnails are the silent killer. A catalogue page with 24 course cards, each with a 1200×800 JPEG thumbnail loading eagerly, will clock in at 8–15MB on first paint. Use loading="lazy", srcSet with WebP variants at 400w and 800w, and size the <img> to the actual display size (typically 320×180px at 2x for a card thumbnail). You can shave 80% off that payload with those three changes alone.
The VideoPlayer component above leaves keyboard support as an exercise. Here's the quick implementation — add this onKeyDown to the wrapper div and tabIndex={0} to make it focusable:
const handleKeyDown = (e: React.KeyboardEvent) => {
const v = videoRef.current!;
switch (e.key) {
case ' ':
case 'k': e.preventDefault(); toggle(); break;
case 'ArrowLeft': v.currentTime = Math.max(0, v.currentTime - 10); break;
case 'ArrowRight': v.currentTime = Math.min(v.duration, v.currentTime + 10); break;
case 'm': setMuted((prev) => !prev); break;
case 'f': v.requestFullscreen(); break;
}
};In practice, the two accessibility gaps that get EdTech products dinged the hardest in audits are: missing focus-visible rings on custom controls, and progress bars without role="progressbar" and aria-valuenow. Both are 5-minute fixes. Don't let them slip. You can browse components in Empire UI that ship with these attributes already baked in — it's not charity, it's just less work.
FAQ
Native <video> is totally fine for most EdTech use cases. Video.js and Plyr add 100KB+ of JS you probably don't need. Build a thin custom controls wrapper like the one in this guide — it handles 95% of learner needs and you control the styling completely.
4px (h-1 in Tailwind) for subtle in-card progress, 6–8px (h-1.5 to h-2) for primary progress displays, and 12px (h-3) for hero-level stats like overall course completion. Expand on hover for the seek bar in video players — that's the Spotify pattern and it works.
Stack vertically: video full-width first, then lesson title and module progress, then the sidebar content collapses into an accordion. The lg:grid-cols-[1fr_340px] pattern in the dashboard skeleton above handles this cleanly — single column on mobile, two-column on large screens.
It depends on your audience. Corporate and enterprise training platforms do best with clean light-mode cards and subtle gradients. Consumer-facing bootcamps and creative courses can pull off aurora or glassmorphism overlays for a premium feel. Check the glassmorphism components and aurora styles — both have dashboard-ready variants.