Course Card
Original · freeA course card with thumbnail, lesson count and progress bar.
Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.
npx shadcn@latest add https://webinnoventix.com/r/card-course.json"use client";
import { useEffect, useId, useRef, useState } from "react";
import { useReducedMotion } from "motion/react";
type Level = "Beginner" | "Intermediate" | "Advanced";
type Course = {
id: string;
title: string;
category: string;
instructor: string;
thumb: string;
level: Level;
lessons: number;
hours: number;
rating: number;
learners: string;
progress: number; // 0-100
};
const COURSES: Course[] = [
{
id: "react-patterns",
title: "Advanced React Patterns & Performance",
category: "Frontend Engineering",
instructor: "Dan Nguyen",
thumb: "/img/gallery/g07.webp",
level: "Advanced",
lessons: 42,
hours: 8.5,
rating: 4.9,
learners: "12.4k",
progress: 68,
},
{
id: "python-data",
title: "Python for Data Science, End to End",
category: "Data & Analytics",
instructor: "Priya Raman",
thumb: "/img/gallery/g18.webp",
level: "Intermediate",
lessons: 56,
hours: 11,
rating: 4.8,
learners: "34.1k",
progress: 100,
},
{
id: "ui-foundations",
title: "UI Design Foundations in Figma",
category: "Product Design",
instructor: "Marco Feddersen",
thumb: "/img/gallery/g24.webp",
level: "Beginner",
lessons: 28,
hours: 5,
rating: 4.7,
learners: "9.2k",
progress: 0,
},
];
const COMPACT: Course = {
id: "rust-systems",
title: "Systems Programming with Rust",
category: "Backend & Systems",
instructor: "Elena Kovač",
thumb: "/img/gallery/g31.webp",
level: "Advanced",
lessons: 37,
hours: 9.5,
rating: 4.9,
learners: "6.8k",
progress: 34,
};
const LEVEL_STYLES: Record<Level, string> = {
Beginner:
"bg-emerald-100 text-emerald-700 ring-emerald-600/20 dark:bg-emerald-500/15 dark:text-emerald-300 dark:ring-emerald-400/25",
Intermediate:
"bg-sky-100 text-sky-700 ring-sky-600/20 dark:bg-sky-500/15 dark:text-sky-300 dark:ring-sky-400/25",
Advanced:
"bg-violet-100 text-violet-700 ring-violet-600/20 dark:bg-violet-500/15 dark:text-violet-300 dark:ring-violet-400/25",
};
function IconLessons() {
return (
<svg viewBox="0 0 24 24" fill="none" className="h-4 w-4" aria-hidden="true">
<path
d="M4 5.5A1.5 1.5 0 0 1 5.5 4H11v15H5.5A1.5 1.5 0 0 1 4 17.5v-12ZM13 4h5.5A1.5 1.5 0 0 1 20 5.5v12a1.5 1.5 0 0 1-1.5 1.5H13V4Z"
stroke="currentColor"
strokeWidth="1.6"
strokeLinejoin="round"
/>
</svg>
);
}
function IconClock() {
return (
<svg viewBox="0 0 24 24" fill="none" className="h-4 w-4" aria-hidden="true">
<circle cx="12" cy="12" r="8.25" stroke="currentColor" strokeWidth="1.6" />
<path d="M12 7.5V12l3 2" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
function IconStar() {
return (
<svg viewBox="0 0 24 24" className="h-4 w-4 fill-amber-400" aria-hidden="true">
<path d="M12 2.5l2.9 5.88 6.49.94-4.7 4.58 1.11 6.46L12 17.3l-5.8 3.05 1.1-6.46-4.69-4.58 6.49-.94L12 2.5Z" />
</svg>
);
}
function IconBookmark({ filled }: { filled: boolean }) {
return (
<svg viewBox="0 0 24 24" className="h-5 w-5" aria-hidden="true">
<path
d="M6 4.5A1.5 1.5 0 0 1 7.5 3h9A1.5 1.5 0 0 1 18 4.5V21l-6-3.6L6 21V4.5Z"
fill={filled ? "currentColor" : "none"}
stroke="currentColor"
strokeWidth="1.6"
strokeLinejoin="round"
/>
</svg>
);
}
function IconCheck() {
return (
<svg viewBox="0 0 24 24" fill="none" className="h-4 w-4" aria-hidden="true">
<path d="M5 12.5l4 4 10-10" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
function IconPlay() {
return (
<svg viewBox="0 0 24 24" className="h-4 w-4 fill-current" aria-hidden="true">
<path d="M8 5.5v13l11-6.5-11-6.5Z" />
</svg>
);
}
function IconSpinner() {
return (
<svg viewBox="0 0 24 24" fill="none" className="cc-spin h-4 w-4" aria-hidden="true">
<circle cx="12" cy="12" r="9" stroke="currentColor" strokeWidth="2.4" className="opacity-25" />
<path d="M21 12a9 9 0 0 0-9-9" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" />
</svg>
);
}
function ProgressBar({ value, animate }: { value: number; animate: boolean }) {
const labelId = useId();
const done = value >= 100;
return (
<div>
<div className="mb-1.5 flex items-center justify-between text-xs font-medium">
<span id={labelId} className="text-slate-500 dark:text-slate-400">
{done ? "Completed" : value > 0 ? "In progress" : "Not started"}
</span>
<span className="tabular-nums text-slate-700 dark:text-slate-200">{value}%</span>
</div>
<div
role="progressbar"
aria-valuenow={value}
aria-valuemin={0}
aria-valuemax={100}
aria-labelledby={labelId}
className="h-2 w-full overflow-hidden rounded-full bg-slate-200/80 dark:bg-slate-700/60"
>
<div
className={
"relative h-full rounded-full transition-[width] duration-700 ease-out motion-reduce:transition-none " +
(done
? "bg-emerald-500 dark:bg-emerald-400"
: "bg-gradient-to-r from-indigo-500 to-violet-500 dark:from-indigo-400 dark:to-violet-400")
}
style={{ width: `${animate ? value : 0}%` }}
>
{!done && value > 0 && (
<span className="cc-shimmer pointer-events-none absolute inset-0 rounded-full" aria-hidden="true" />
)}
</div>
</div>
</div>
);
}
function CourseCard({ course }: { course: Course }) {
const reduce = useReducedMotion();
const [saved, setSaved] = useState(false);
const [enrolled, setEnrolled] = useState(course.progress > 0);
const [loading, setLoading] = useState(false);
const [animate, setAnimate] = useState(false);
const cardRef = useRef<HTMLDivElement | null>(null);
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
if (reduce) {
setAnimate(true);
return;
}
const el = cardRef.current;
if (!el) return;
const io = new IntersectionObserver(
(entries) => {
if (entries.some((e) => e.isIntersecting)) {
setAnimate(true);
io.disconnect();
}
},
{ threshold: 0.35 },
);
io.observe(el);
return () => io.disconnect();
}, [reduce]);
useEffect(() => () => {
if (timer.current) clearTimeout(timer.current);
}, []);
const done = course.progress >= 100;
function handleAction() {
if (loading) return;
setLoading(true);
timer.current = setTimeout(() => {
setLoading(false);
setEnrolled(true);
}, 900);
}
const ctaLabel = done ? "Review course" : enrolled ? "Continue learning" : "Start course";
return (
<div
ref={cardRef}
className="group flex flex-col overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm ring-1 ring-transparent transition-shadow duration-300 hover:shadow-xl hover:shadow-slate-900/5 dark:border-slate-700/70 dark:bg-slate-900 dark:hover:shadow-black/40"
>
<div className="relative aspect-[16/10] overflow-hidden">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={course.thumb}
alt={`${course.title} course thumbnail`}
loading="lazy"
draggable={false}
className="h-full w-full object-cover transition-transform duration-500 ease-out group-hover:scale-[1.04]"
/>
<div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-black/45 via-black/0 to-black/10" />
<span
className={
"absolute left-3 top-3 rounded-full px-2.5 py-1 text-[11px] font-semibold uppercase tracking-wide ring-1 ring-inset " +
LEVEL_STYLES[course.level]
}
>
{course.level}
</span>
<button
type="button"
aria-pressed={saved}
aria-label={saved ? `Remove ${course.title} from saved` : `Save ${course.title}`}
onClick={() => setSaved((s) => !s)}
className={
"absolute right-3 top-3 grid h-9 w-9 place-items-center rounded-full backdrop-blur transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-offset-2 focus-visible:ring-offset-slate-900/40 " +
(saved
? "bg-amber-400 text-slate-900"
: "bg-black/35 text-white hover:bg-black/55")
}
>
<IconBookmark filled={saved} />
</button>
<span className="absolute bottom-3 right-3 flex items-center gap-1 rounded-full bg-black/55 px-2.5 py-1 text-[11px] font-medium text-white backdrop-blur">
<IconClock />
{course.hours}h
</span>
</div>
<div className="flex flex-1 flex-col p-4">
<div className="mb-2 flex items-center justify-between text-xs">
<span className="font-semibold uppercase tracking-wide text-indigo-600 dark:text-indigo-400">
{course.category}
</span>
<span className="flex items-center gap-1 font-medium text-slate-600 dark:text-slate-300">
<IconStar />
<span className="tabular-nums">{course.rating.toFixed(1)}</span>
</span>
</div>
<h3 className="text-[15px] font-semibold leading-snug text-slate-900 dark:text-slate-50">
{course.title}
</h3>
<div className="mt-2.5 flex items-center gap-2">
<span
aria-hidden="true"
className="grid h-6 w-6 place-items-center rounded-full bg-gradient-to-br from-indigo-500 to-violet-500 text-[11px] font-bold text-white"
>
{course.instructor
.split(" ")
.map((p) => p[0])
.join("")}
</span>
<span className="text-xs text-slate-500 dark:text-slate-400">{course.instructor}</span>
</div>
<div className="mt-3 flex items-center gap-4 text-xs font-medium text-slate-500 dark:text-slate-400">
<span className="flex items-center gap-1.5">
<IconLessons />
{course.lessons} lessons
</span>
<span className="flex items-center gap-1.5">
<svg viewBox="0 0 24 24" fill="none" className="h-4 w-4" aria-hidden="true">
<path
d="M12 12.5a3.75 3.75 0 1 0 0-7.5 3.75 3.75 0 0 0 0 7.5ZM5 19.5a7 7 0 0 1 14 0"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
/>
</svg>
{course.learners}
</span>
</div>
<div className="mt-4">
<ProgressBar value={course.progress} animate={animate} />
</div>
<button
type="button"
onClick={handleAction}
disabled={loading}
className={
"mt-4 inline-flex items-center justify-center gap-2 rounded-xl px-4 py-2.5 text-sm font-semibold transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed dark:focus-visible:ring-offset-slate-900 " +
(done
? "bg-emerald-600 text-white hover:bg-emerald-500 focus-visible:ring-emerald-500"
: "bg-slate-900 text-white hover:bg-slate-700 focus-visible:ring-slate-500 dark:bg-white dark:text-slate-900 dark:hover:bg-slate-200")
}
>
{loading ? (
<>
<IconSpinner />
Loading…
</>
) : (
<>
{done ? <IconCheck /> : <IconPlay />}
{ctaLabel}
</>
)}
</button>
</div>
</div>
);
}
function CompactCourseCard({ course }: { course: Course }) {
const reduce = useReducedMotion();
const [saved, setSaved] = useState(true);
const [animate, setAnimate] = useState(false);
useEffect(() => {
const id = window.setTimeout(() => setAnimate(true), reduce ? 0 : 120);
return () => window.clearTimeout(id);
}, [reduce]);
return (
<div className="flex items-center gap-4 rounded-2xl border border-slate-200 bg-white p-3 shadow-sm transition-shadow hover:shadow-md dark:border-slate-700/70 dark:bg-slate-900">
<div className="relative h-20 w-28 shrink-0 overflow-hidden rounded-xl">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={course.thumb}
alt={`${course.title} course thumbnail`}
loading="lazy"
draggable={false}
className="h-full w-full object-cover"
/>
<span
className={
"absolute left-1.5 top-1.5 rounded-full px-1.5 py-0.5 text-[10px] font-semibold ring-1 ring-inset " +
LEVEL_STYLES[course.level]
}
>
{course.level}
</span>
</div>
<div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="text-[11px] font-semibold uppercase tracking-wide text-indigo-600 dark:text-indigo-400">
{course.category}
</p>
<h3 className="truncate text-sm font-semibold text-slate-900 dark:text-slate-50">
{course.title}
</h3>
</div>
<button
type="button"
aria-pressed={saved}
aria-label={saved ? `Remove ${course.title} from saved` : `Save ${course.title}`}
onClick={() => setSaved((s) => !s)}
className={
"grid h-8 w-8 shrink-0 place-items-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-900 " +
(saved
? "text-amber-500"
: "text-slate-400 hover:text-slate-600 dark:hover:text-slate-200")
}
>
<IconBookmark filled={saved} />
</button>
</div>
<div className="mt-1.5 flex items-center gap-3 text-[11px] font-medium text-slate-500 dark:text-slate-400">
<span className="flex items-center gap-1">
<IconLessons />
{course.lessons}
</span>
<span className="flex items-center gap-1">
<IconClock />
{course.hours}h
</span>
<span className="flex items-center gap-1">
<IconStar />
{course.rating.toFixed(1)}
</span>
</div>
<div className="mt-2.5">
<ProgressBar value={course.progress} animate={animate} />
</div>
</div>
</div>
);
}
export default function CardCourse() {
return (
<section className="relative w-full bg-slate-50 px-4 py-16 sm:px-6 lg:py-24 dark:bg-slate-950">
<style>{`
@keyframes cc-shimmer-x {
0% { transform: translateX(-100%); }
100% { transform: translateX(200%); }
}
@keyframes cc-spin-r {
to { transform: rotate(360deg); }
}
.cc-shimmer {
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.55), transparent);
animation: cc-shimmer-x 1.8s linear infinite;
}
.cc-spin { animation: cc-spin-r 0.7s linear infinite; }
@media (prefers-reduced-motion: reduce) {
.cc-shimmer { animation: none; }
.cc-spin { animation: none; }
}
`}</style>
<div className="mx-auto max-w-6xl">
<header className="mb-10 max-w-2xl">
<p className="text-sm font-semibold uppercase tracking-widest text-indigo-600 dark:text-indigo-400">
Course cards
</p>
<h2 className="mt-2 text-3xl font-bold tracking-tight text-slate-900 sm:text-4xl dark:text-slate-50">
Pick up where you left off
</h2>
<p className="mt-3 text-base text-slate-600 dark:text-slate-400">
Thumbnail, lesson count and live progress. Save a course, then start or continue —
every control is a real, keyboard-focusable button.
</p>
</header>
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
{COURSES.map((c) => (
<CourseCard key={c.id} course={c} />
))}
</div>
<div className="mt-10">
<h3 className="mb-4 text-sm font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">
Continue list — compact
</h3>
<div className="mx-auto max-w-2xl">
<CompactCourseCard course={COMPACT} />
</div>
</div>
</div>
</section>
);
}Dependencies
Licence
Built by Web Innoventix. Free for personal and commercial use, no attribution required.
Built by Web Innoventix
Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.
Get a free quoteSimilar components
Browse all →
3D Tilt Cards
OriginalA responsive grid of cards that tilt in 3D toward your cursor with a light glare and lifted depth layers, powered by Framer Motion springs.

Spotlight Follow Cards
OriginalFeature cards where a soft radial spotlight and a glowing border chase the cursor on hover, with a staggered scroll reveal and an animated shimmer line.

3D Flip Cards
OriginalPricing-style cards that flip in 3D on hover or tap to reveal details on the back, keyboard accessible with a staggered entrance animation.

Animated Gradient Border Cards
OriginalCards wrapped in a live conic gradient border that rotates and speeds up on hover, with a shimmer sweep and a scrolling tag marquee.

Glow Effect Card
primitivesA reusable glow effect card for React and Tailwind, with hover and motion.

Neon Gradient Card
gradient-card.tsxA reusable neon gradient card for React and Tailwind, with hover and motion.

Spotlight Magic Card
card.tsxA reusable spotlight magic card for React and Tailwind, with hover and motion.

Tilt Spotlight Card
primitivesA reusable tilt spotlight card for React and Tailwind, with hover and motion.

Profile Card
OriginalA user profile card with avatar, stats and a follow toggle.

Product Card
OriginalA product card with image, price and add-to-cart feedback.

Pricing Single Card
OriginalA single pricing plan card with feature list and CTA.

Stat Card
OriginalA metric card with value, trend arrow and a small sparkline.

