EdTech

Course Card

A course card with a gradient thumbnail, instructor, partial-fill star rating, duration/level/learner meta, and an enroll CTA or progress bar.

Install

npx shadcn@latest add @paragon/course-card

Also installs: button

course-card.tsx

"use client";

import * as React from "react";
import { Star, Clock, Users, BarChart3, PlayCircle } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/registry/paragon/ui/button";

export interface CourseCardProps extends React.ComponentProps<"div"> {
  title?: string;
  instructor?: string;
  /** Average rating 0–5. */
  rating?: number;
  ratingCount?: number;
  /** Total duration label, e.g. "8h 20m". */
  duration?: string;
  level?: string;
  learners?: number;
  /** Completion fraction 0–1 when enrolled; omit for not-yet-enrolled. */
  progress?: number;
  /** Accent gradient endpoints for the thumbnail. */
  gradient?: [string, string];
  onEnroll?: () => void;
}

function Stars({ rating }: { rating: number }) {
  return (
    <span className="flex items-center gap-0.5" aria-hidden>
      {[0, 1, 2, 3, 4].map((i) => {
        const fill = Math.max(0, Math.min(1, rating - i));
        return (
          <span key={i} className="relative inline-block">
            <Star className="size-3.5 text-border" />
            <span
              className="absolute inset-0 overflow-hidden"
              style={{ width: `${fill * 100}%` }}
            >
              <Star className="size-3.5 fill-warning text-warning" />
            </span>
          </span>
        );
      })}
    </span>
  );
}

/**
 * A course card: a gradient thumbnail with a play affordance, title, instructor,
 * a partial-fill star rating, meta chips (duration, level, learners), and either
 * an enrolled progress bar (scaleX) or an enroll CTA. Uses the Button primitive
 * and tabular numerals for counts.
 */
export function CourseCard({
  title = "Designing Data-Intensive Systems",
  instructor = "Dr. Elena Marković",
  rating = 4.7,
  ratingCount = 3240,
  duration = "8h 20m",
  level = "Intermediate",
  learners = 18400,
  progress,
  gradient = ["#6366f1", "#a855f7"],
  onEnroll,
  className,
  ...props
}: CourseCardProps) {
  const enrolled = progress != null;
  const pct = enrolled ? Math.round(Math.min(Math.max(progress, 0), 1) * 100) : 0;

  return (
    <div
      data-slot="course-card"
      className={cn(
        "w-full max-w-xs overflow-hidden rounded-xl bg-card text-card-foreground shadow-border",
        className,
      )}
      {...props}
    >
      <div
        className="relative flex aspect-[16/9] items-center justify-center"
        style={{
          background: `linear-gradient(135deg, ${gradient[0]}, ${gradient[1]})`,
        }}
      >
        <PlayCircle className="size-10 text-white/90" aria-hidden />
        <span className="absolute right-2 top-2 rounded-full bg-black/30 px-2 py-0.5 text-[11px] font-medium text-white tabular-nums backdrop-blur-sm">
          {duration}
        </span>
      </div>

      <div className="p-4">
        <h3 className="text-sm font-semibold leading-tight text-balance">
          {title}
        </h3>
        <p className="mt-0.5 text-xs text-muted-foreground">{instructor}</p>

        <div className="mt-2 flex items-center gap-1.5">
          <span className="text-sm font-semibold tabular-nums text-warning">
            {rating.toFixed(1)}
          </span>
          <Stars rating={rating} />
          <span className="text-xs text-muted-foreground tabular-nums">
            ({ratingCount.toLocaleString("en-US")})
          </span>
        </div>

        <div className="mt-3 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground">
          <span className="flex items-center gap-1">
            <Clock className="size-3" />
            {duration}
          </span>
          <span className="flex items-center gap-1">
            <BarChart3 className="size-3" />
            {level}
          </span>
          <span className="flex items-center gap-1 tabular-nums">
            <Users className="size-3" />
            {learners.toLocaleString("en-US")}
          </span>
        </div>

        {enrolled ? (
          <div className="mt-4">
            <div className="flex items-center justify-between text-xs">
              <span className="text-muted-foreground">Your progress</span>
              <span className="font-medium tabular-nums">{pct}%</span>
            </div>
            <div className="mt-1.5 h-1.5 overflow-hidden rounded-full bg-secondary">
              <div
                className="h-full origin-left rounded-full bg-primary transition-transform duration-500 ease-out"
                style={{ transform: `scaleX(${pct / 100})` }}
              />
            </div>
            <Button variant="outline" className="mt-3 w-full">
              Continue learning
            </Button>
          </div>
        ) : (
          <Button className="mt-4 w-full" onClick={onEnroll}>
            Enroll now
          </Button>
        )}
      </div>
    </div>
  );
}