A setup-progress card with a progress ring, checkmarks that draw in, label strikes that sweep, and one restrained ring burst on completion.
npx shadcn@latest add @paragon/onboarding-checklist"use client";
import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { ArrowUpRight } from "lucide-react";
import { cn } from "@/lib/utils";
interface Task {
id: string;
label: string;
description: string;
}
const TASKS: Task[] = [
{
id: "project",
label: "Create your first project",
description: "Spin up a project to hold environments and deploys.",
},
{
id: "repo",
label: "Connect a Git repository",
description: "Every push to main builds and deploys automatically.",
},
{
id: "team",
label: "Invite your team",
description: "Reviews and rollbacks work better with more than one of you.",
},
{
id: "domain",
label: "Configure a custom domain",
description: "Point production at your own domain with managed TLS.",
},
{
id: "previews",
label: "Enable preview environments",
description: "Each pull request gets an isolated, shareable deploy.",
},
];
const RING_R = 17;
const RING_C = 2 * Math.PI * RING_R;
export interface OnboardingChecklistProps
extends React.ComponentProps<"section"> {
/** Task ids that start completed. */
defaultCompleted?: string[];
}
/**
* A setup-progress card: SVG progress ring, task rows whose checkmarks draw
* in via a normalized pathLength dash transition while the label strike
* sweeps left-to-right. Completing the final task fires one soft ring burst —
* a single scale-and-fade halo, no confetti.
*/
export function OnboardingChecklist({
defaultCompleted = ["project", "repo", "team"],
className,
...props
}: OnboardingChecklistProps) {
const reducedMotion = useReducedMotion();
const [done, setDone] = React.useState<Set<string>>(
() => new Set(defaultCompleted),
);
const [burst, setBurst] = React.useState(0);
const prevCount = React.useRef(done.size);
const count = done.size;
const total = TASKS.length;
const allDone = count === total;
React.useEffect(() => {
if (count === total && prevCount.current < total) {
setBurst((b) => b + 1);
}
prevCount.current = count;
}, [count, total]);
const toggle = (id: string) => {
setDone((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
return (
<section
aria-label="Workspace setup checklist"
className={cn(
"w-full max-w-md rounded-xl bg-card p-5 text-card-foreground shadow-border sm:p-6",
className,
)}
{...props}
>
<header className="flex items-center gap-4">
{/* Progress ring */}
<div className="relative shrink-0">
<svg
viewBox="0 0 44 44"
className="size-11 -rotate-90"
role="img"
aria-label={`${count} of ${total} setup tasks complete`}
>
<circle
cx="22"
cy="22"
r={RING_R}
fill="none"
strokeWidth="3"
className="stroke-muted"
/>
<circle
cx="22"
cy="22"
r={RING_R}
fill="none"
strokeWidth="3"
strokeLinecap="round"
strokeDasharray={RING_C}
strokeDashoffset={RING_C * (1 - count / total)}
className={cn(
"stroke-emerald-600 dark:stroke-emerald-400",
"motion-safe:transition-[stroke-dashoffset] motion-safe:duration-300 motion-safe:[transition-timing-function:var(--ease-out)]",
)}
/>
</svg>
<span
aria-hidden
className="absolute inset-0 flex rotate-0 items-center justify-center text-[11px] font-semibold tabular-nums"
>
{count}/{total}
</span>
{/* Single soft ring burst on completion */}
<AnimatePresence>
{burst > 0 && allDone && !reducedMotion && (
<motion.span
key={burst}
aria-hidden
initial={{ opacity: 0.5, scale: 1 }}
animate={{ opacity: 0, scale: 1.55 }}
transition={{ duration: 0.5, ease: "easeOut" }}
onAnimationComplete={() => setBurst(0)}
className="pointer-events-none absolute inset-0 rounded-full border-2 border-emerald-500/60"
/>
)}
</AnimatePresence>
</div>
<div className="min-w-0">
<h2 className="text-sm font-semibold">
{allDone ? "Setup complete" : "Finish setting up Relay"}
</h2>
<p
className="mt-0.5 text-[13px] text-muted-foreground tabular-nums"
aria-live="polite"
>
{allDone
? "You’re ready to ship to production."
: `${count} of ${total} complete`}
</p>
</div>
</header>
<ul className="mt-4 space-y-0.5">
{TASKS.map((task) => {
const checked = done.has(task.id);
return (
<li key={task.id}>
<button
type="button"
role="checkbox"
aria-checked={checked}
onClick={() => toggle(task.id)}
className={cn(
"group flex w-full items-start gap-3 rounded-lg px-2.5 py-2.5 text-left outline-none",
"transition-[background-color] duration-150 ease-out hover:bg-muted/60",
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-card",
)}
>
{/* Check circle */}
<span
aria-hidden
className={cn(
"mt-0.5 flex size-[18px] shrink-0 items-center justify-center rounded-full border",
"transition-[background-color,border-color] duration-200 ease-out",
checked
? "border-emerald-600 bg-emerald-600 dark:border-emerald-500 dark:bg-emerald-500"
: "border-muted-foreground/40 group-hover:border-muted-foreground/70",
)}
>
<svg viewBox="0 0 12 12" className="size-3" fill="none">
<path
d="M2.5 6.5L5 9L9.5 3.5"
stroke="white"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
pathLength={1}
strokeDasharray={1}
strokeDashoffset={checked ? 0 : 1}
className="motion-safe:transition-[stroke-dashoffset] motion-safe:duration-300 motion-safe:[transition-timing-function:var(--ease-out)]"
style={
reducedMotion && !checked ? { opacity: 0 } : undefined
}
/>
</svg>
</span>
<span className="min-w-0">
<span
className={cn(
"relative inline-block text-sm font-medium",
"transition-[color] duration-200 ease-out",
checked && "text-muted-foreground",
)}
>
{task.label}
{/* Strike sweeps in from the left */}
<span
aria-hidden
className={cn(
"absolute top-1/2 left-0 h-px w-full origin-left bg-current",
"motion-safe:transition-transform motion-safe:duration-300 motion-safe:[transition-timing-function:var(--ease-out)]",
"transition-opacity duration-150",
checked
? "scale-x-100 opacity-100"
: "scale-x-0 opacity-0",
)}
/>
</span>
<span className="mt-0.5 block text-[13px] text-pretty text-muted-foreground/80">
{task.description}
</span>
</span>
</button>
</li>
);
})}
</ul>
<footer className="mt-4 border-t pt-4">
<a
href="#docs"
className="inline-flex items-center gap-1 text-[13px] font-medium text-muted-foreground transition-colors duration-150 hover:text-foreground"
>
Read the quickstart guide
<ArrowUpRight aria-hidden className="size-3.5" />
</a>
</footer>
</section>
);
}