Navigation

Course Module Tree

A nested module/lesson tree with completion checks that draw in and per-module plus overall progress.

Install

npx shadcn@latest add @paragon/course-module-tree

course-module-tree.tsx

"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>
  );
}