A nested module/lesson tree with completion checks that draw in and per-module plus overall progress.
npx shadcn@latest add @paragon/course-module-tree"use client";
import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { ChevronRight, Circle } from "lucide-react";
import { cn } from "@/lib/utils";
export interface CourseLesson {
id: string;
title: string;
/** Duration label, e.g. "8 min". */
duration?: string;
done?: boolean;
}
export interface CourseModule {
id: string;
title: string;
lessons: CourseLesson[];
/** Open by default. */
defaultOpen?: boolean;
}
export interface CourseModuleTreeProps
extends React.ComponentProps<"div"> {
modules: CourseModule[];
/** Suppresses the check draw and expand animation. */
static?: boolean;
}
/**
* A nested course tree: modules expand to reveal their lessons. Completed
* lessons show a check whose ring draws in (stroke-dashoffset) the first time
* it renders; per-module and overall progress are computed from the data. The
* expand uses grid-template-rows 0fr→1fr so no height is animated. Reduced
* motion drops the draw and expand transitions.
*/
export function CourseModuleTree({
modules,
static: isStatic = false,
className,
...props
}: CourseModuleTreeProps) {
const reducedMotion = useReducedMotion();
const animate = !isStatic && !reducedMotion;
const totals = React.useMemo(() => {
const all = modules.flatMap((m) => m.lessons);
const done = all.filter((l) => l.done).length;
return { done, total: all.length };
}, [modules]);
const overallPct =
totals.total > 0 ? Math.round((totals.done / totals.total) * 100) : 0;
return (
<div
data-slot="course-module-tree"
className={cn(
"w-full max-w-md rounded-xl bg-card p-4 shadow-border",
className,
)}
{...props}
>
<div className="mb-3 flex items-center justify-between gap-3 px-1">
<span className="text-sm font-medium">Course progress</span>
<span className="text-xs text-muted-foreground tabular-nums">
{totals.done}/{totals.total} · {overallPct}%
</span>
</div>
<div
aria-hidden
className="mb-3 h-1.5 overflow-hidden rounded-full bg-secondary"
>
<div
className="h-full origin-left rounded-full bg-primary"
style={{
transform: `scaleX(${overallPct / 100})`,
transition: animate
? "transform 500ms var(--ease-out)"
: undefined,
}}
/>
</div>
<ul className="flex flex-col gap-1">
{modules.map((module) => (
<ModuleRow key={module.id} module={module} animate={animate} />
))}
</ul>
</div>
);
}
function ModuleRow({
module,
animate,
}: {
module: CourseModule;
animate: boolean;
}) {
const [open, setOpen] = React.useState(module.defaultOpen ?? false);
const done = module.lessons.filter((l) => l.done).length;
const contentId = React.useId();
return (
<li>
<button
type="button"
aria-expanded={open}
aria-controls={contentId}
onClick={() => setOpen((o) => !o)}
className="flex w-full items-center gap-2 rounded-lg px-2 py-2 text-left outline-none transition-colors duration-150 hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
>
<ChevronRight
aria-hidden
className={cn(
"size-4 shrink-0 text-muted-foreground transition-transform duration-200",
open && "rotate-90",
)}
/>
<span className="min-w-0 flex-1 text-sm font-medium">
{module.title}
</span>
<span className="shrink-0 text-xs text-muted-foreground tabular-nums">
{done}/{module.lessons.length}
</span>
</button>
{/* grid-rows morph — no height animation. */}
<div
id={contentId}
className="grid transition-[grid-template-rows] duration-200 ease-out motion-reduce:transition-none"
style={{ gridTemplateRows: open ? "1fr" : "0fr" }}
>
<div className="overflow-hidden">
<ul className="ml-3 flex flex-col gap-0.5 border-l border-border pt-0.5 pl-3">
{module.lessons.map((lesson) => (
<li key={lesson.id}>
<div className="flex items-center gap-2.5 rounded-md px-2 py-1.5">
<LessonMark done={!!lesson.done} animate={animate && open} />
<span
className={cn(
"min-w-0 flex-1 truncate text-sm",
lesson.done
? "text-muted-foreground"
: "text-foreground",
)}
>
{lesson.title}
</span>
{lesson.duration && (
<span className="shrink-0 text-xs text-muted-foreground tabular-nums">
{lesson.duration}
</span>
)}
</div>
</li>
))}
</ul>
</div>
</div>
</li>
);
}
function LessonMark({ done, animate }: { done: boolean; animate: boolean }) {
if (!done) {
return (
<span className="flex size-5 shrink-0 items-center justify-center text-muted-foreground">
<Circle className="size-4" />
</span>
);
}
return (
<span className="flex size-5 shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground">
<svg viewBox="0 0 24 24" className="size-3" aria-hidden>
<motion.path
d="M4 12.5l5 5 11-11"
fill="none"
stroke="currentColor"
strokeWidth={3}
strokeLinecap="round"
strokeLinejoin="round"
initial={animate ? { pathLength: 0 } : false}
animate={{ pathLength: 1 }}
transition={{ duration: 0.35, ease: [0.22, 1, 0.36, 1] }}
/>
</svg>
</span>
);
}