Curriculum
Course-layout container for a sequence of lessons or modules with title, progress, optional intro, and a stack of lesson rows.
Preview
Switch between light and dark to inspect the embedded Storybook preview.
Installation
pnpm dlx shadcn@latest add https://ui.vllnt.ai/r/curriculum.jsonStorybook
Explore all variants, controls, and accessibility checks in the interactive Storybook playground.
View in StorybookCode
"use client";
import {
Children,
createContext,
isValidElement,
useCallback,
useContext,
useMemo,
useState,
} from "react";
import {
BookOpen,
CheckCircle2,
ChevronDown,
Clock,
GraduationCap,
Link2,
Lock,
PlayCircle,
} from "lucide-react";
import type { ReactNode } from "react";
import { cn } from "../../lib/utils";
export type LessonStatus = "available" | "completed" | "in-progress" | "locked";
export type LessonDifficulty = "advanced" | "beginner" | "intermediate";
type CurriculumContextValue = {
expandedModules: Set<string>;
toggleModule: (id: string) => void;
};
const CurriculumContext = createContext<CurriculumContextValue | null>(null);
function useCurriculumContext(): CurriculumContextValue {
const ctx = useContext(CurriculumContext);
if (!ctx) {
throw new Error("CurriculumModule must be used within a Curriculum");
}
return ctx;
}
type LessonProgressSummary = {
completed: number;
total: number;
};
type LessonElementProps = {
children?: ReactNode;
status?: LessonStatus;
};
function summarizeLessonProgress(children: ReactNode): LessonProgressSummary {
let completed = 0;
let total = 0;
Children.forEach(children, (child) => {
if (!isValidElement<LessonElementProps>(child)) {
return;
}
if (child.type === CurriculumLesson) {
total += 1;
if (child.props.status === "completed") {
completed += 1;
}
return;
}
const nested = summarizeLessonProgress(child.props.children);
total += nested.total;
completed += nested.completed;
});
return { completed, total };
}
function statusLabel(status: LessonStatus): string {
if (status === "completed") return "Completed";
if (status === "in-progress") return "In progress";
if (status === "locked") return "Locked";
return "Available";
}
function statusIcon(status: LessonStatus): ReactNode {
if (status === "completed") {
return <CheckCircle2 className="size-4 flex-shrink-0 text-green-500" />;
}
if (status === "in-progress") {
return <PlayCircle className="size-4 flex-shrink-0 text-primary" />;
}
if (status === "locked") {
return <Lock className="size-4 flex-shrink-0 text-muted-foreground" />;
}
return <BookOpen className="size-4 flex-shrink-0 text-muted-foreground" />;
}
const difficultyStyles: Record<LessonDifficulty, string> = {
advanced: "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400",
beginner:
"bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400",
intermediate:
"bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400",
};
type ProgressBarProps = {
completed: number;
progressPct: number;
total: number;
};
function ProgressBar({
completed,
progressPct,
total,
}: ProgressBarProps): React.ReactNode {
if (total === 0) return null;
return (
<div className="mt-1 flex items-center gap-2">
<div className="h-1.5 max-w-[120px] flex-1 overflow-hidden rounded-full bg-muted">
<div
className="h-full rounded-full bg-primary transition-all"
style={{ width: `${progressPct}%` }}
/>
</div>
<span className="text-xs text-muted-foreground">
{completed}/{total}
</span>
</div>
);
}
type LessonMetaProps = {
difficulty?: LessonDifficulty;
duration?: string;
prerequisites?: string[];
};
function LessonMeta({
difficulty,
duration,
prerequisites,
}: LessonMetaProps): React.ReactNode {
const prerequisitesLabel = prerequisites?.length
? `Requires: ${prerequisites.join(", ")}`
: null;
return (
<div className="flex flex-shrink-0 items-center gap-2">
{prerequisitesLabel ? (
<span
aria-label={prerequisitesLabel}
className="flex items-center gap-1 text-xs text-muted-foreground"
title={prerequisitesLabel}
>
<Link2 aria-hidden="true" className="size-3" />
</span>
) : null}
{difficulty ? (
<span
className={cn(
"rounded px-1.5 py-0.5 text-xs font-medium capitalize",
difficultyStyles[difficulty],
)}
>
{difficulty}
</span>
) : null}
{duration ? (
<span className="flex items-center gap-1 text-xs text-muted-foreground">
<Clock className="size-3" />
{duration}
</span>
) : null}
</div>
);
}
type ModuleTriggerProps = {
completed: number;
description?: string;
estimatedHours?: number;
id: string;
isExpanded: boolean;
progressPct: number;
title: string;
toggle: () => void;
total: number;
};
function ModuleTrigger({
completed,
description,
estimatedHours,
id,
isExpanded,
progressPct,
title,
toggle,
total,
}: ModuleTriggerProps): React.ReactNode {
return (
<button
aria-controls={`module-content-${id}`}
aria-expanded={isExpanded}
className={cn(
"w-full flex items-start justify-between gap-3 rounded-md px-6 py-4 text-left transition-colors",
"hover:bg-muted/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2",
isExpanded && "bg-muted/30",
)}
onClick={toggle}
type="button"
>
<div className="flex min-w-0 flex-col gap-1">
<span className="text-sm font-medium">{title}</span>
{description ? (
<span className="line-clamp-1 text-xs text-muted-foreground">
{description}
</span>
) : null}
<ProgressBar
completed={completed}
progressPct={progressPct}
total={total}
/>
</div>
<div className="mt-0.5 flex flex-shrink-0 items-center gap-3">
{estimatedHours === undefined ? null : (
<span className="flex items-center gap-1 text-xs text-muted-foreground">
<Clock className="size-3.5" />
{estimatedHours}h
</span>
)}
<ChevronDown
className={cn(
"size-4 text-muted-foreground transition-transform duration-200",
isExpanded && "rotate-180",
)}
/>
</div>
</button>
);
}
export type CurriculumProps = {
children: ReactNode;
className?: string;
defaultExpandedModules?: string[];
title: string;
totalHours?: number;
};
function CurriculumRoot({
children,
className,
defaultExpandedModules,
title,
totalHours,
}: CurriculumProps): React.ReactNode {
const [expandedModules, setExpandedModules] = useState<Set<string>>(
() => new Set(defaultExpandedModules ?? []),
);
const toggleModule = useCallback((id: string) => {
setExpandedModules((previous) => {
const next = new Set(previous);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
}, []);
const contextValue = useMemo(
() => ({ expandedModules, toggleModule }),
[expandedModules, toggleModule],
);
return (
<CurriculumContext.Provider value={contextValue}>
<div
className={cn(
"my-6 rounded-lg border bg-card text-card-foreground shadow-sm",
className,
)}
>
<div className="flex items-center justify-between border-b px-6 py-4">
<div className="flex items-center gap-2">
<GraduationCap className="size-5 text-primary" />
<h2 className="text-lg font-semibold">{title}</h2>
</div>
{totalHours === undefined ? null : (
<div className="flex items-center gap-1 text-sm text-muted-foreground">
<Clock className="size-4" />
<span>{totalHours}h total</span>
</div>
)}
</div>
<div aria-label={title} className="divide-y">
{children}
</div>
</div>
</CurriculumContext.Provider>
);
}
export type CurriculumModuleProps = {
children: ReactNode;
className?: string;
description?: string;
estimatedHours?: number;
id: string;
title: string;
};
function CurriculumModule({
children,
className,
description,
estimatedHours,
id,
title,
}: CurriculumModuleProps): React.ReactNode {
const { expandedModules, toggleModule } = useCurriculumContext();
const isExpanded = expandedModules.has(id);
const { completed, total } = useMemo(
() => summarizeLessonProgress(children),
[children],
);
const progressPct = total > 0 ? Math.round((completed / total) * 100) : 0;
return (
<section className={className}>
<ModuleTrigger
completed={completed}
description={description}
estimatedHours={estimatedHours}
id={id}
isExpanded={isExpanded}
progressPct={progressPct}
title={title}
toggle={() => {
toggleModule(id);
}}
total={total}
/>
<div
aria-hidden={!isExpanded}
className={cn(
"overflow-hidden transition-all duration-200",
isExpanded ? "max-h-[9999px] opacity-100" : "max-h-0 opacity-0",
)}
hidden={!isExpanded}
id={`module-content-${id}`}
>
<div className="divide-y divide-border/50 pb-2">{children}</div>
</div>
</section>
);
}
export type CurriculumLessonProps = {
className?: string;
difficulty?: LessonDifficulty;
duration?: string;
href?: string;
id?: string;
prerequisites?: string[];
status?: LessonStatus;
title: string;
};
function CurriculumLesson({
className,
difficulty,
duration,
href,
prerequisites,
status = "available",
title,
}: CurriculumLessonProps): React.ReactNode {
const isLocked = status === "locked";
const inner = (
<div
aria-disabled={isLocked || undefined}
className={cn(
"flex items-center gap-3 px-6 py-3 pl-10 text-sm transition-colors",
isLocked ? "cursor-not-allowed opacity-60" : "",
!isLocked && href ? "cursor-pointer hover:bg-muted/40" : "",
className,
)}
>
{statusIcon(status)}
<span className="sr-only">{statusLabel(status)}</span>
<span
className={cn(
"min-w-0 flex-1 truncate",
status === "completed" && "text-muted-foreground line-through",
status === "in-progress" && "font-medium",
)}
>
{title}
{isLocked ? <span className="sr-only"> (Locked)</span> : null}
</span>
<LessonMeta
difficulty={difficulty}
duration={duration}
prerequisites={prerequisites}
/>
</div>
);
if (href && !isLocked) {
return (
<a
className="block rounded-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2"
href={href}
>
{inner}
</a>
);
}
return inner;
}
type CurriculumComponent = ((props: CurriculumProps) => React.ReactNode) & {
Lesson: typeof CurriculumLesson;
Module: typeof CurriculumModule;
};
const Curriculum = Object.assign(CurriculumRoot, {
Lesson: CurriculumLesson,
Module: CurriculumModule,
}) as CurriculumComponent;
export { Curriculum, CurriculumLesson, CurriculumModule };