Progress Tracker
Curriculum-level learning dashboard for modules, lessons, exercises, streaks, and earned skills.
Preview
Switch between light and dark to inspect the embedded Storybook preview.
Installation
pnpm dlx shadcn@latest add https://ui.vllnt.ai/r/progress-tracker.jsonStorybook
Explore all variants, controls, and accessibility checks in the interactive Storybook playground.
View in Storybook2 stories available:
Code
"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.useContext(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,
};