Curriculum

Course-layout container for a sequence of lessons or modules with title, progress, optional intro, and a stack of lesson rows. Install Curriculum from the VLLNT UI registry with the shadcn CLI.

Report a bug

Course-layout container for a sequence of lessons or modules with title, progress, optional intro, and a stack of lesson rows. Part of the learning family in VLLNT UI, it ships as a machine-readable registry entry — copy the source directly into your app with the shadcn CLI and own it, no runtime dependency on a component library.

Preview

Switch between light and dark to inspect the embedded Storybook preview.

Installation

Add Curriculum to your project with the shadcn CLI. The source lands in your codebase, ready to adapt:

pnpm dlx shadcn@latest add https://ui.vllnt.ai/r/curriculum.json

Source

"use client"; import { Children, createContext, isValidElement, use, useCallback, 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 = use(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 };

Stories

Explore every variant and state in the interactive Storybook:

Preview

Switch between light and dark to inspect the embedded Storybook preview.

Dependencies

  • @vllnt/ui@^0.3.0