Web InnoventixFreeCode

Course Card

Original · free

A course card with thumbnail, lesson count and progress bar.

byWeb InnoventixReact + Tailwind
cardcoursecards
Open

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
card-course.tsx
"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

motion

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 quote

Similar components

Browse all →