Onboarding Checklist
A getting-started panel driven by a task contract: a progress ring, rows that expand to a description and one real action, instructions that collapse themselves once a task is ticked, an honest time-left figure, and an inline celebration whose dismiss says where to find the panel again.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/onboarding-checklist.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "OnboardingChecklist" block with zod and
lucide-react; cn() (clsx + tailwind-merge) for classes, no other dependency.
This is the "finish setting up your account" panel a SaaS keeps on the dashboard:
a progress ring, one row per task, each row expanding to what the task means and
the single thing to go and do about it. Its job is to be honest about how far in
the account is, and then to get out of the way once there is nothing left.
Contract
- A zod schema (`onboardingChecklistSchema` in a sibling contract file) is the
single source of truth and props are z.infer of it — never a parallel
interface: { status; title; subtitle?; asOf; tasks[]; errorMessage? }.
- status is "loading" | "empty" | "error" | "ready" — the panel's own render
state, unrelated to how much of the setup is finished. Progress lives in tasks.
- OnboardingTask = { id; title; description?; done; action?; estimateMinutes?;
completedAt? }.
* done is the ONLY source of truth for completion. completedAt is merely the
timestamp of it and is ignored while done is false.
* estimateMinutes: null, absent, 0, negative and non-finite all mean UNKNOWN,
which is not the same as zero.
* completedAt: an ISO instant, or null when nobody recorded one.
- OnboardingAction = { label; href? } — DATA, never a callback. An action with an
href renders an <a>; one without renders a <button> ONLY when the consumer
passed onTaskAction. An action that can do neither is not painted at all: a
dead call-to-action on an onboarding panel is worse than no call-to-action.
- asOf is an ISO instant and is the ONLY clock. Never call Date.now(): every
"done 2h ago" derives from asOf, so the same payload always renders the same
panel and SSR matches hydration.
- tasks render in the given order and are NEVER reordered by completion — a row
that jumps under the pointer costs more than the tidiness it buys.
- Component props = the schema type plus: skeletonRows? (3, clamped 1-8),
defaultExpandedTaskIds? (uncontrolled initial value only), dismissible? (true),
defaultDismissed? (false), onDismissedChange?, dismissHint? ("You can reopen it
any time from Settings → Getting started."), onTaskToggle?(taskId, next),
onTaskAction?(task), onRetry?. forwardRef<HTMLElement>, extends
Omit<React.HTMLAttributes<HTMLElement>, "title">, rest spread on the root
<section>. Omit onTaskToggle and no tick control is painted, which is exactly
what a read-only checklist driven by your backend wants; omit onRetry and the
error branch carries no button.
- Export summarizeOnboarding(tasks) -> { total; done; remaining; ratio; percent;
complete; remainingMinutes; hasUnknownEstimate } so a nav badge, a settings row
or a lifecycle email reuses the arithmetic instead of growing a second opinion
about how far in this account is. Export formatMinutes and formatAgo too.
Behavior — four branches, not one plus three afterthoughts
- loading: the heading and the dismiss control survive (they are known before the
payload lands); below them a pulsing ring placeholder and skeletonRows rows,
aria-hidden, with aria-busy on the root.
- empty: one centered panel saying there is nothing to set up.
- error: a destructive-bordered panel printing errorMessage, falling back to a
sentence that admits the progress is UNKNOWN — never "you are all done" — plus
the retry button when onRetry was passed.
- ready: ring, header line, task list, and the celebration once everything is
ticked.
- status "ready" with zero tasks renders the EMPTY branch: a 0-of-0 ring sitting
at 0% announces a failure to load that never happened.
Behavior — the maths
- ratio = done / total (0 when total is 0). The ring is drawn from the exact
ratio: circumference C = 2 * pi * r, strokeDasharray = C, strokeDashoffset =
C * (1 - clamp01(ratio)), on an svg rotated -90deg so it starts at 12 o'clock.
- The PRINTED percentage is rounded, then guarded: clamped to 99 while any task
is still open and to 1 once anything is done. 199 of 200 rounds to 100, and a
"100%" printed above an open task is the one lie this panel can tell.
- The accessible value is the COUNT, not the percentage: role="progressbar" with
aria-valuemin 0, aria-valuemax total, aria-valuenow done, plus an
aria-valuetext sentence. A count cannot be rounded into a false claim.
- Time left = the sum of the usable estimates on the UNFINISHED tasks. If any
unfinished task has no usable estimate the figure prints with a trailing plus
("about 18 min+ left"); if none of them is estimated the clause is dropped
entirely rather than promising a total of zero.
- formatMinutes prints "45 min", "1 h 20 min", "2 h" — largest unit first, zeros
dropped. formatAgo prints the largest unit only and clamps anything under a
minute to "just now", which is also where a FUTURE completedAt lands: clock
skew between two services is normal, a negative age is not.
- A completedAt that will not parse simply drops the age: the row still reads
"Done", never "Invalid Date" and never NaN.
Behavior — expansion, completion and focus
- Expansion is a Set of ids that starts as null, meaning "the consumer has not
touched anything yet". While it is null the panel expands the first unfinished
task that has something to disclose, computed from the CURRENT tasks — so it
still points at the right row when the data arrives after the first paint,
which a useState initialiser would not.
- Several panels may be open at once. Completion is the only thing that closes
one for you: once a step is done, the instructions telling you how to do it are
noise. Detect the flip by comparing a key built from the done ids against the
previous render and adjust state DURING render (React's documented pattern for
storing information from previous renders), not in an effect — an effect paints
the finished task open for one frame first.
- A row is a disclosure only when it has a description or an action. That depends
on static copy and never on `done`, so a control can never vanish out from
under the keyboard when a task is ticked.
- The host usually marks a task done the moment its action succeeds, which can
collapse a panel the caret is inside. After any collapse, move focus to that
row's own disclosure button when it has fallen to the document body OR is
still standing on a control inside the panel that just closed — the browser
runs its focus fixup for a subtree that went `hidden` AFTER the effect, so a
check for `document.body` alone reads the old answer and never fires. Never
steal focus when it did not fall: a collapse the user asked for should leave
them on the button they just pressed.
- Dismissing does not drop anything into a void: the panel swaps itself for a
one-line strip that repeats dismissHint and carries a Reopen button, and focus
moves onto that button. Reopening moves focus back to the dismiss control, or
to the heading (tabIndex -1) when the panel is not dismissible.
onDismissedChange fires in both directions so the choice can be persisted, and
defaultDismissed reads it back.
- At 100% the panel celebrates INLINE: no overlay, no portal, nothing that
swallows a click, the finished list still readable underneath, and the way back
stated before the Hide button is pressed. Finishing a checklist must not take
the page hostage.
Behavior — keyboard and ARIA
- Root is a <section aria-labelledby> pointing at the heading. The dismissed strip
has no heading, so there the section names itself with aria-label instead of
leaving aria-labelledby dangling at an id that no longer exists.
- Per row: a <button role="checkbox" aria-checked> whose label is
`Mark <title> as done` / `as not done` (painted only when onTaskToggle exists),
then a <button aria-expanded aria-controls> carrying the title, the meta chip
and the chevron. With no tick control the row carries a visually hidden
"Done." / "Not done." so the state is still announced.
- Tab and Shift+Tab move between the controls: this is a list of buttons, not a
composite widget, so there is no roving tabindex. Enter and Space activate all
of them natively, including the role=checkbox button. Escape on an expanded row
collapses it and calls stopPropagation, so a surrounding dialog keeps its own
Escape for the second press.
- Toggle the panel with the `hidden` attribute on a bare wrapper — not with a
collapsed 0fr grid track, which keeps its tab stops and turns a closed panel
into an invisible keyboard trap, and not on an element that also carries a
display utility, which out-ranks [hidden] in older Tailwind builds.
- One persistent live region: <span role="status" aria-atomic class="sr-only">
that speaks only when the panel changes PHASE (the branch, the done count, or
dismissal). The first paint is the baseline and is never announced, so a screen
reader is not read the whole panel on load.
- Actions and retry fire once per burst. The guard is a Map of locks held in a
ref, read AND written synchronously inside the handler, because a state flag is
only visible after a re-render and the second click of a double click lands
well before that. Locks are per key, so two different tasks never block each
other, and each re-arms on a ~900ms timer: a considered second press is a real
retry, and a locked-forever button is worse than a duplicated request. Paint
the locked state with aria-disabled plus the handler guard, never with the
native disabled attribute — the browser blurs a focused disabled control to the
document body and the keyboard user loses their place.
Rendering & styling
- Semantic tokens only, no hex / rgb / oklch anywhere: bg-card + border +
rounded-xl for the panel, bg-muted/40 for expanded panels,
text-muted-foreground for secondary text, stroke-muted and stroke-primary for
the ring, bg-primary + text-primary-foreground for the tick and the CTA,
border-primary/30 + bg-primary/5 for the celebration, border-destructive/40 and
text-destructive for the error panel, ring-ring focus-visible rings with
ring-offset-background.
- The ring is one svg with two circles: the rendered size comes from a class
(size-14) and the geometry from viewBox user units, so resizing it is one class
edit and no maths.
- Merge the consumer className with cn() on the root; titles, descriptions and
the hint all take wrap-anywhere, so a 69-character unbroken slug wraps instead
of overflowing the card.
- Reduced motion: motion-reduce:transition-none on the ring sweep and the chevron
rotation, motion-reduce:animate-none on the skeleton pulse and the retry
spinner. Nothing functional depends on any of it — the ring is correct at rest.
- Cleanup: clear every burst timer on unmount and before it re-arms. The panel
owns no other timer, listener or observer.
Customization levers
- Sub-blocks: dismissible={false} drops the X and the celebration's Hide button;
omit subtitle for a two-line header; omit onTaskToggle for a list your backend
drives entirely; leave every estimateMinutes out and the "time left" clause
disappears on its own.
- Density: the ring is size-14 and rows are px-4 py-3 — halve both for a sidebar
widget. RING_STROKE (6) and the viewBox are the only numbers the geometry
reads.
- Disclosure policy: this list allows several open panels and only closes one on
completion. For a single-open accordion, swap the Set for one id; to keep
finished instructions readable, delete the render-phase collapse.
- What is next: defaultExpandedTaskIds={[]} starts fully collapsed, or pass the id
your backend thinks is most urgent instead of taking the first unfinished one.
- Burst window: ACTION_BURST_MS (900) is how long an action stays locked. Raise it
for anything that creates a resource, lower it for pure navigation.
- Copy: dismissHint is the promise the panel makes about where to find it again —
point it at a route you actually have. Everything else is inline English.
- Palette: the ring, the tick and the celebration are the only coloured things.
Swap stroke-primary for var(--chart-2) and the panel reads as part of a
dashboard's data viz rather than as an action panel.Concepts
- Completion collapses, the order never moves — ticking a task closes its instructions, because how-to copy is noise once the job is done, and that is the only collapse the panel performs for you. What it never does is reorder: finished rows stay exactly where they were, so nothing jumps under a pointer that was already on its way somewhere.
- Injected clock —
asOfis a prop, notDate.now(). Every “done 2h ago” derives from it, which is what makes the panel renderable on the server, screenshot-stable, and identical between SSR and hydration; a stamp from a service whose clock runs ahead clamps to “just now” instead of printing a negative age. - Ready-with-nothing is empty — a payload that arrived fine but carries no tasks falls into the empty branch instead of drawing a 0-of-0 ring at 0%. “No tasks” and “no progress” are different claims, and only one of them is true.
- Honest arithmetic — the accessible value is the count, never the rounded percentage, and the printed percentage is clamped so it cannot claim a finished list while a task is open. The same honesty runs through the effort figure: an unfinished task with no estimate turns “about 18 min left” into “about 18 min+ left” rather than quietly promising a number nobody can back.
- Deliberate focus successors — a task can flip to done while the caret sits inside its open panel, and dismissing removes the very control that was pressed. Both hand focus somewhere chosen: the row’s own disclosure button, the Reopen button, the dismiss control. Focus is only rescued when it actually fell, so a collapse the user asked for still leaves them where they were.
- A terminal state that steps aside — at 100% the panel celebrates in flow: no overlay, no portal, nothing intercepting clicks, the finished list still readable underneath. Then it offers to go away, and states where to find it again before the button is pressed rather than after.
Subscription Manager
A manage-plan block — the running plan with its renewal countdown and seat overage, usage against the plan's own limits, prorated plan changes, and a cancellation that states what is lost and when access actually ends.
Feature Announcement
A what's-new surface — modal dialog or anchored popover — that steps through the features a reader has not seen yet and reports every seen id plus the reason it closed.