Steps Checklist
A dismissible onboarding checklist — jump-around completion, a required-only progress bar, per-step expandable instructions with a CTA, and a dependency-free celebration state.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/steps-checklist.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "StepsChecklist" component — the "finish
these 5 things" panel a SaaS shows on first login. lucide-react for icons, cn()
(clsx + tailwind-merge) for classes, and NO confetti / animation library: the
celebration is plain CSS keyframes.
Contract
- export const StepsChecklist = React.forwardRef<HTMLElement, StepsChecklistProps>,
remaining props spread onto the root <section>. Props: items: ChecklistItem[];
title? (default "Getting started", also the accessible name of the progress bar);
onItemToggle?: (id: string) => void; expandedId?: string | null +
onExpandedChange?: (id: string | null) => void (controlled accordion pair —
uncontrolled it keeps its own state and starts fully closed); onDismiss?: () =>
void; showProgress? (default true); celebrateOnComplete? (default true);
collapsedByDefault? (default false); className.
- ChecklistItem = { id: string; title: string; description?: string; done: boolean;
optional?: boolean; cta?: { label: string; href?: string; onClick?: () => void };
expandedContent?: React.ReactNode; estimateLabel?: string }.
- Completion is fully controlled: `done` is the consumer's truth (it usually comes
from the backend — "has this account connected a data source?"). onItemToggle is
what turns the tick into a control; omit it and the checklist is read-only.
- Duplicate ids are dropped, first occurrence wins: they would collide on React keys
and on aria-controls, and toggling one would toggle its twin.
Behavior
- Progress counting, the one rule to get right: the denominator is the REQUIRED
steps (optional !== true). Optional steps carry a badge and never move the bar, so
"3 of 5 complete" always means "3 of the 5 things you must do". A list where every
item is optional would leave the denominator at 0, so it falls back to counting all
items. An empty list renders no progress bar at all — a "0 of 0" bar is noise — and
can never celebrate.
- Accordion: a row is expandable only if it has a description, expandedContent, or a
usable cta. Opening a row closes the previously open one (single-open); clicking the
open row closes it. Rows with nothing to reveal render as plain text, never as a
button that does nothing.
- Completed rows keep their place: the tick fills in (bg-primary), the title goes
muted + line-through, and the row stays expandable so the instructions are still
reachable. Nothing reorders — a checklist that resorts itself under the cursor loses
the user's place.
- CTA: `href` renders an <a>, otherwise `onClick` renders a <button>; a cta with
neither is skipped entirely rather than drawn as a dead button. A CTA never marks
its own step done — the app ticks the step when the work actually lands (a link CTA
navigates away and comes back completed).
- Celebration: when every counted step is done and celebrateOnComplete is on, the list
is REPLACED by a celebration panel — one sentence plus the dismiss button when
onDismiss exists. The burst is CSS only: a scale "pop" on the badge and 8 dots at
fixed 45° angles (deterministic — no Math.random during render) travelling outward
once via `rotate(var(--angle)) translateY(-30px)`. Under prefers-reduced-motion the
dots are hidden and the pop is dropped; the panel itself still appears.
- Collapse: the header is always the toggle (aria-expanded + aria-controls on the
body); collapsedByDefault only picks the initial state. Collapsed still shows the
title, the "3 of 5 complete" counter and the progress bar, so the block folds to a
single progress row. The body is hidden with the `hidden` attribute, which takes the
collapsed content out of the tab order and the a11y tree — not just out of sight.
- Dismiss is a request: the component never unmounts itself, the consumer owns
visibility (and usually persists "dismissed" per user). The header X and the
celebration button are the same action, so exactly one of them is rendered.
- Announcements: one polite sr-only live region reports "N of M steps complete" (or
"All M steps complete"), but only after the count has been quiet for ~700ms.
Ticking four boxes in a row produces one announcement instead of four, a change that
cancels itself inside the window announces nothing, and the first paint is treated
as the baseline and never announced.
Rendering & styling
- Semantic tokens only: bg-card / text-card-foreground + border + rounded-xl (root),
bg-muted (progress track), bg-primary + text-primary-foreground (fill, completed
tick, celebration badge, sparks, CTA), text-muted-foreground (counter, descriptions,
completed titles, optional badge), ring-ring for focus-visible. No hex, rgb() or
oklch().
- A11y skeleton: <ul role="list"> with <li> as DIRECT children (a role-less div in
between makes screen readers announce an empty list). The tick is a <button
aria-pressed>, deliberately not a checkbox: it is an action ("mark this step done"),
it is frequently absent (read-only checklists), and a disabled checkbox would still
claim to be an editable value. When onItemToggle is missing the tick becomes a plain
indicator carrying an sr-only "Completed" / "Not completed". The title button owns
aria-expanded + aria-controls; the panel is role="region" aria-labelledby its
trigger and gets `inert` while collapsed. The bar is role="progressbar" with
aria-valuemin=0 / aria-valuenow=<done> / aria-valuemax=<total> expressed in STEPS,
not percent, plus aria-valuetext "3 of 5 complete".
- Motion: panels animate grid-template-rows 0fr -> 1fr (no height measuring),
chevrons rotate, the fill transitions width — each with motion-reduce:*-none.
Keyframes ship inside the component through a React 19 hoisted <style href
precedence>, so several mounted checklists dedupe to one stylesheet.
- Long titles wrap (min-w-0 + break-words) instead of overflowing; the header title
truncates. Everything works at card width.
Customization levers
- Counting policy: the denominator is one expression (`required.length > 0 ? required
: items`). Swap it to count everything, to weight optional steps as bonus progress,
or to show "2 required left" instead of a ratio.
- Tick affordance: round + filled is the default; rounded-md gives a checkbox look,
and swapping Check for a number renders an ordered "do these in order" list. Change
the button to role="checkbox" only if the user really owns the value.
- Disclosure: keep the accordion, or drop the trigger and render every description
inline for a short list; pass `expandedId` = the first incomplete step's id to
auto-open the next thing to do (the demo does exactly this after each CTA).
- Celebration: it is a self-contained branch — restyle the sentence, keep the list
visible and show a banner above it instead, or tune SPARKS (count/angle/distance)
and the pop duration. Set celebrateOnComplete={false} for a checklist that should
stay a checklist.
- Chatter: ANNOUNCE_DELAY is the quiet window before the live region speaks; raise it
for bulk-completing flows, drop the region entirely if the surrounding page already
announces the same thing.
- Density and chrome: px-4 py-3 rows, size-5 ticks and h-1.5 bar are the knobs for a
compact sidebar variant; remove the root border for a flush in-page section, or
render the whole block inside your own card.Concepts
- Required-only denominator — the progress denominator counts required steps only;
optionalsteps get a badge and are never counted. "3 of 5" therefore always means "3 of the 5 things you must do", and adding an optional step never drags progress backwards. A list where everything is optional would leave the denominator at 0, so it falls back to counting all items. - Progressive disclosure — collapsed rows carry the title, the time estimate and the completion state; the description, the illustrative content and the CTA live in the expanded panel. One row opens at a time (accordion), so the list stays scannable at a glance and still has room for real instructions once opened.
- Completion without reordering — a ticked step stays where it is and picks up a line-through plus a filled tick; it does not sink to the bottom or vanish. Position is the user's spatial memory, and a list that resorts itself under the cursor makes them find everything again.
- CTA hand-off — the CTA only hands the user to the place the work actually happens (a route or your handler); it never marks its own step done. Completion is written back from the app's real state, otherwise the checklist starts lying. A CTA with neither
hrefnoronClickis not rendered at all rather than drawn as a button that does nothing. - Celebration as a terminal state — finishing everything is not "put a green tick on each row": the whole block is replaced by one celebratory sentence plus a way out. This space has done its job and should be handed back to the page. The burst is pure CSS (8 dots at fixed angles plus a single scale pop), and under
prefers-reduced-motiononly the static panel remains. - Dismiss as a request —
onDismissis just the signal that the user wants it gone; the component never unmounts itself. Visibility (and persisting "this user already dismissed it") stays with the consumer, because whether it comes back after a refresh is a product decision. - Coalesced announcement — the completed count is announced through a polite live region, but only after ~700ms of quiet: ticking four boxes in a row says "4 of 5 steps complete" once, a tick that is undone inside the window says nothing at all, and the first paint is treated as the baseline and never announced.
Uptime Bars
A status-page availability strip — one bar per day bucketed into operational / degraded / partial / major, with hover-or-focus incident detail and a width-aware day window.
Split Pane
A dependency-free resizable split layout — percentage flex-basis panes, per-pane percent/px minimums, double-click collapse, full keyboard resizing, and an optional layout that survives reloads.