A course card with a gradient thumbnail, instructor, partial-fill star rating, duration/level/learner meta, and an enroll CTA or progress bar.
npx shadcn@latest add @paragon/course-cardAlso installs: button
"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>
);
}