Blocks

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import {
  ArrowRight,
  Check,
  ChevronDown,
  CircleAlert,
  ListChecks,
  PartyPopper,
  RotateCw,
  X,
} from "lucide-react"
import { cn } from "@/lib/utils"

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/onboarding-checklist.json

Prompt

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 clockasOf is a prop, not Date.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.

On This Page