Progress Tracker

Curriculum-level learning dashboard for modules, lessons, exercises, streaks, and earned skills. Install Progress Tracker from the VLLNT UI registry with the shadcn CLI.

Report a bug

Curriculum-level learning dashboard for modules, lessons, exercises, streaks, and earned skills. 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 Progress Tracker to your project with the shadcn CLI. The source lands in your codebase, ready to adapt:

pnpm dlx shadcn@latest add https://ui.vllnt.com/r/progress-tracker.json

Source

"use client"; import * as React from "react"; import type { ReactNode } from "react"; import { cn } from "../../lib/utils"; import { Badge } from "../badge/badge"; import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from "../card/card"; import { CHECKLIST_PROGRESS_EVENT, type ChecklistItem, parseChecklistStorageValue, } from "../checklist/checklist"; import { ProgressBar } from "../progress-bar/progress-bar"; export type ProgressTrackerModuleStatus = | "available" | "completed" | "in-progress" | "locked"; export type ProgressTrackerModuleItem = { badge?: string; checklistItems?: ChecklistItem[]; completedExercises?: number; completedLessons?: number; currentLesson?: string; description?: string; exercises?: number; href?: string; id?: string; lessons: number; persistKey?: string; progress: number; skills?: string[]; status: ProgressTrackerModuleStatus; timeSpent?: string; title: string; }; export type ProgressTrackerProps = React.HTMLAttributes<HTMLDivElement> & { children?: ReactNode; modules?: ProgressTrackerModuleItem[]; overallProgress: number; streak?: number; title?: string; }; type ProgressTrackerContextValue = { modules: ProgressTrackerModuleItem[]; overallProgress: number; streak: number; title?: string; }; const ProgressTrackerContext = React.createContext<null | ProgressTrackerContextValue>(null); function clampPercentage(value: number): number { if (Number.isNaN(value)) return 0; if (value <= 1) return Math.round(Math.max(0, value) * 100); return Math.round(Math.min(Math.max(0, value), 100)); } const STATUS_LABELS: Record<ProgressTrackerModuleStatus, string> = { available: "Available", completed: "Completed", "in-progress": "In progress", locked: "Locked", }; const STATUS_CLASSES: Record<ProgressTrackerModuleStatus, string> = { available: "border-secondary bg-secondary text-secondary-foreground", completed: "border-primary/20 bg-primary text-primary-foreground", "in-progress": "border-primary/20 bg-primary/10 text-primary", locked: "border-border bg-muted text-muted-foreground", }; function getStatusLabel(status: ProgressTrackerModuleStatus): string { return STATUS_LABELS[status]; } function getStatusClasses(status: ProgressTrackerModuleStatus): string { return STATUS_CLASSES[status]; } function readPersistedChecklistItems(persistKey?: string): string[] { if (!persistKey || typeof window === "undefined") return []; try { const saved = localStorage.getItem(`checklist:${persistKey}`); if (!saved) return []; return parseChecklistStorageValue(saved); } catch { return []; } } function areStringArraysEqual(left: string[], right: string[]): boolean { return ( left.length === right.length && left.every((value, index) => value === right[index]) ); } function getChecklistPersistKey(event?: Event): null | string { if (!(event instanceof CustomEvent)) return null; const detail: unknown = event.detail; if (typeof detail !== "object" || detail === null) return null; if (!("persistKey" in detail)) return null; const { persistKey } = detail; return typeof persistKey === "string" ? persistKey : null; } function getResolvedLessonProgress(module: ProgressTrackerModuleItem): { completedLessons: number; totalLessons: number; } { if (!module.persistKey || !module.checklistItems?.length) { return { completedLessons: module.completedLessons ?? 0, totalLessons: module.lessons, }; } const validIds = new Set(module.checklistItems.map((item) => item.id)); const persistedIds = readPersistedChecklistItems(module.persistKey); const completedLessons = persistedIds.filter((id) => validIds.has(id)).length; return { completedLessons, totalLessons: module.checklistItems.length, }; } function useChecklistProgress( checklistItems: ChecklistItem[] = [], persistKey?: string, ): null | { completedCount: number; progress: number; total: number } { const total = checklistItems.length; const [persistedIds, setPersistedIds] = React.useState<string[]>(() => readPersistedChecklistItems(persistKey), ); const [trackedPersistKey, setTrackedPersistKey] = React.useState(persistKey); const setPersistedIdsIfChanged = React.useCallback((nextIds: string[]) => { setPersistedIds((currentIds) => areStringArraysEqual(currentIds, nextIds) ? currentIds : nextIds, ); }, []); if (trackedPersistKey !== persistKey) { setTrackedPersistKey(persistKey); setPersistedIdsIfChanged(readPersistedChecklistItems(persistKey)); } React.useEffect(() => { if (!persistKey || typeof window === "undefined") return; const sync = (event?: Event): void => { const eventPersistKey = getChecklistPersistKey(event); if (eventPersistKey && eventPersistKey !== persistKey) return; setPersistedIdsIfChanged(readPersistedChecklistItems(persistKey)); }; const syncEventListener: EventListener = (event) => { sync(event); }; window.addEventListener("storage", sync); window.addEventListener("focus", sync); window.addEventListener(CHECKLIST_PROGRESS_EVENT, syncEventListener); return () => { window.removeEventListener("storage", sync); window.removeEventListener("focus", sync); window.removeEventListener(CHECKLIST_PROGRESS_EVENT, syncEventListener); }; }, [persistKey, setPersistedIdsIfChanged]); if (!persistKey || total === 0) return null; const validIds = new Set(checklistItems.map((item) => item.id)); const completedCount = persistedIds.filter((id) => validIds.has(id)).length; return { completedCount, progress: total > 0 ? Math.round((completedCount / total) * 100) : 0, total, }; } function useProgressTrackerContext(): ProgressTrackerContextValue { const context = React.use(ProgressTrackerContext); if (!context) { throw new Error( "ProgressTracker compound components must be used within <ProgressTracker />.", ); } return context; } const EMPTY_PROGRESS_TRACKER_MODULES: ProgressTrackerModuleItem[] = []; const EMPTY_PROGRESS_TRACKER_SKILLS: string[] = []; function ProgressTrackerRoot({ children, className, modules = EMPTY_PROGRESS_TRACKER_MODULES, overallProgress, streak = 0, title = "Learning progress", ...props }: ProgressTrackerProps): React.ReactNode { const contextValue = React.useMemo( () => ({ modules, overallProgress: clampPercentage(overallProgress), streak, title, }), [modules, overallProgress, streak, title], ); return ( <ProgressTrackerContext.Provider value={contextValue}> <section aria-label={title} className={cn("grid gap-6", className)} {...props} > {children} </section> </ProgressTrackerContext.Provider> ); } export type ProgressTrackerOverviewProps = React.HTMLAttributes<HTMLDivElement> & { description?: string; label?: string; }; // eslint-disable-next-line max-lines-per-function function ProgressTrackerOverview({ className, description = "Track completion across modules, lessons, and exercises.", label = "Overall progress", ...props }: ProgressTrackerOverviewProps): React.ReactNode { const { modules, overallProgress, streak, title } = useProgressTrackerContext(); const trackedPersistKeys = React.useMemo( () => modules.reduce<string[]>((keys, module) => { if (module.persistKey) { keys.push(module.persistKey); } return keys; }, []), [modules], ); const [, forceChecklistRefresh] = React.useState(0); React.useEffect(() => { if (trackedPersistKeys.length === 0 || typeof window === "undefined") { return; } const trackedKeys = new Set(trackedPersistKeys); const sync = (event?: Event): void => { const eventPersistKey = getChecklistPersistKey(event); if (eventPersistKey && !trackedKeys.has(eventPersistKey)) return; forceChecklistRefresh((version) => version + 1); }; const syncEventListener: EventListener = (event) => { sync(event); }; window.addEventListener("storage", sync); window.addEventListener("focus", sync); window.addEventListener(CHECKLIST_PROGRESS_EVENT, syncEventListener); return () => { window.removeEventListener("storage", sync); window.removeEventListener("focus", sync); window.removeEventListener(CHECKLIST_PROGRESS_EVENT, syncEventListener); }; }, [trackedPersistKeys]); const radius = 54; const circumference = 2 * Math.PI * radius; const offset = circumference - (overallProgress / 100) * circumference; const completedModules = modules.filter( (module) => module.status === "completed", ).length; const lessonTotals = modules.reduce( (totals, module) => { const resolvedProgress = getResolvedLessonProgress(module); return { completedLessons: totals.completedLessons + resolvedProgress.completedLessons, totalLessons: totals.totalLessons + resolvedProgress.totalLessons, }; }, { completedLessons: 0, totalLessons: 0 }, ); const totalLessons = lessonTotals.totalLessons; const completedLessons = lessonTotals.completedLessons; const totalExercises = modules.reduce( (sum, module) => sum + (module.exercises ?? 0), 0, ); const completedExercises = modules.reduce( (sum, module) => sum + (module.completedExercises ?? 0), 0, ); return ( <Card className={cn("overflow-hidden", className)} {...props}> <CardHeader className="gap-4 sm:flex-row sm:items-start sm:justify-between"> <div className="space-y-2"> <Badge className="w-fit" variant="secondary"> {label} </Badge> <div className="space-y-1"> <CardTitle>{title}</CardTitle> <CardDescription>{description}</CardDescription> </div> </div> <div className="flex items-center gap-2 rounded-full border bg-background px-3 py-1 text-sm text-muted-foreground whitespace-nowrap"> <span aria-hidden="true" className="inline-flex size-2.5 rounded-full bg-primary" /> <span> <span className="font-semibold text-foreground">{streak}</span> day {streak === 1 ? "" : "s"} streak </span> </div> </CardHeader> <CardContent className="grid gap-6 lg:grid-cols-[auto,1fr] lg:items-center"> <div className="mx-auto flex flex-col items-center gap-3 text-center"> <div className="relative flex size-36 items-center justify-center"> <svg className="size-36 -rotate-90" viewBox="0 0 120 120"> <circle className="stroke-muted" cx="60" cy="60" fill="none" r={radius} strokeWidth="10" /> <circle aria-label={label} aria-valuemax={100} aria-valuemin={0} aria-valuenow={overallProgress} className="stroke-primary transition-all duration-500" cx="60" cy="60" fill="none" r={radius} role="progressbar" strokeDasharray={circumference} strokeDashoffset={offset} strokeLinecap="round" strokeWidth="10" /> </svg> <div className="absolute inset-0 flex flex-col items-center justify-center"> <span className="text-3xl font-semibold text-foreground"> {overallProgress}% </span> <span className="text-xs uppercase tracking-[0.2em] text-muted-foreground"> Complete </span> </div> </div> <p className="max-w-52 text-sm text-muted-foreground"> {completedModules} of {modules.length} modules completed. </p> </div> <dl className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4"> <div className="rounded-xl border bg-muted/30 p-4"> <dt className="text-sm text-muted-foreground">Modules</dt> <dd className="mt-2 text-2xl font-semibold text-foreground"> {completedModules}/{modules.length} </dd> </div> <div className="rounded-xl border bg-muted/30 p-4"> <dt className="text-sm text-muted-foreground">Lessons</dt> <dd className="mt-2 text-2xl font-semibold text-foreground"> {completedLessons}/{totalLessons} </dd> </div> <div className="rounded-xl border bg-muted/30 p-4"> <dt className="text-sm text-muted-foreground">Exercises</dt> <dd className="mt-2 text-2xl font-semibold text-foreground"> {completedExercises}/{totalExercises} </dd> </div> <div className="rounded-xl border bg-muted/30 p-4"> <dt className="text-sm text-muted-foreground">Momentum</dt> <dd className="mt-2 text-2xl font-semibold text-foreground"> {streak} day{streak === 1 ? "" : "s"} </dd> </div> </dl> </CardContent> </Card> ); } export type ProgressTrackerModulesProps = React.HTMLAttributes<HTMLDivElement>; function ProgressTrackerModules({ children, className, ...props }: ProgressTrackerModulesProps): React.ReactNode { return ( <div className={cn("grid gap-4 md:grid-cols-2 xl:grid-cols-3", className)} {...props} > {children} </div> ); } export type ProgressTrackerModuleProps = React.HTMLAttributes<HTMLDivElement> & ProgressTrackerModuleItem; // eslint-disable-next-line max-lines-per-function function ProgressTrackerModule({ badge, checklistItems, className, completedExercises, completedLessons = 0, currentLesson, description, exercises, href, id, lessons, persistKey, progress, skills = EMPTY_PROGRESS_TRACKER_SKILLS, status, timeSpent, title, ...props }: ProgressTrackerModuleProps): React.ReactNode { const checklistProgress = useChecklistProgress(checklistItems, persistKey); const resolvedLessons = checklistProgress?.completedCount ?? completedLessons; const resolvedLessonTotal = checklistProgress?.total || lessons; const progressPercent = checklistProgress?.progress ?? clampPercentage(progress); const progressValue = Math.min(resolvedLessons, resolvedLessonTotal); const safeExerciseTotal = exercises ?? 0; const safeExerciseComplete = Math.min( completedExercises ?? 0, safeExerciseTotal, ); const card = ( <Card aria-disabled={status === "locked"} className={cn( "h-full", status === "locked" && "opacity-80", href && "transition-shadow hover:shadow-md focus-within:shadow-md", className, )} id={id} {...props} > <CardHeader className="gap-4"> <div className="flex items-start justify-between gap-3"> <div className="space-y-1"> <CardTitle className="text-xl">{title}</CardTitle> {description ? ( <CardDescription>{description}</CardDescription> ) : null} </div> <Badge className={cn("whitespace-nowrap", getStatusClasses(status))} variant="outline" > {getStatusLabel(status)} </Badge> </div> <div className="flex flex-wrap gap-2"> {badge ? <Badge variant="secondary">{badge}</Badge> : null} {currentLesson ? ( <Badge variant="outline">Current: {currentLesson}</Badge> ) : null} {timeSpent ? <Badge variant="outline">{timeSpent}</Badge> : null} </div> </CardHeader> <CardContent className="space-y-4"> <div className="space-y-2"> <div aria-label={`${title} progress`} aria-valuemax={100} aria-valuemin={0} aria-valuenow={progressPercent} role="progressbar" > <ProgressBar completedLabel="lessons" currentLabel={`${progressPercent}% complete`} isComplete={status === "completed"} max={resolvedLessonTotal} showLabels value={progressValue} /> </div> <div className="grid gap-2 text-sm text-muted-foreground sm:grid-cols-2"> <span> Lessons:{" "} <span className="font-medium text-foreground"> {resolvedLessons}/{resolvedLessonTotal} </span> </span> <span> Exercises:{" "} <span className="font-medium text-foreground"> {safeExerciseComplete}/{safeExerciseTotal} </span> </span> </div> </div> {skills.length > 0 ? ( <div className="flex flex-wrap gap-2"> {skills.map((skill) => ( <ProgressTrackerBadge key={skill}>{skill}</ProgressTrackerBadge> ))} </div> ) : null} </CardContent> </Card> ); if (!href || status === "locked") return card; return ( <a className="block rounded-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2" href={href} > {card} </a> ); } export type ProgressTrackerStatsProps = React.HTMLAttributes<HTMLDivElement>; function ProgressTrackerStats({ children, className, ...props }: ProgressTrackerStatsProps): React.ReactNode { return ( <div className={cn("grid gap-4 sm:grid-cols-2 xl:grid-cols-4", className)} {...props} > {children} </div> ); } export type ProgressTrackerStatProps = React.HTMLAttributes<HTMLDivElement> & { label: string; value: ReactNode; }; function ProgressTrackerStat({ className, label, value, ...props }: ProgressTrackerStatProps): React.ReactNode { return ( <Card className={cn("h-full", className)} {...props}> <CardContent className="flex h-full flex-col justify-center gap-2 p-6"> <span className="text-sm text-muted-foreground">{label}</span> <span className="text-2xl font-semibold text-foreground">{value}</span> </CardContent> </Card> ); } export type ProgressTrackerBadgeProps = React.HTMLAttributes<HTMLSpanElement>; function ProgressTrackerBadge({ children, className, ...props }: ProgressTrackerBadgeProps): React.ReactNode { return ( <Badge className={cn( "px-3 py-1 text-[11px] uppercase tracking-wide whitespace-nowrap", className, )} variant="secondary" {...props} > {children} </Badge> ); } const ProgressTracker = Object.assign(ProgressTrackerRoot, { Badge: ProgressTrackerBadge, Module: ProgressTrackerModule, Modules: ProgressTrackerModules, Overview: ProgressTrackerOverview, Stat: ProgressTrackerStat, Stats: ProgressTrackerStats, }); export { ProgressTracker, ProgressTrackerBadge, ProgressTrackerModule, ProgressTrackerModules, ProgressTrackerOverview, ProgressTrackerStat, ProgressTrackerStats, useProgressTrackerContext, };

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