Display

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.

Preview in your theme

Loading preview…

"use client"

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

/**
 * Celebration keyframes ship with the component via a React 19 hoisted <style>:
 * no Tailwind config edit, and several mounted checklists dedupe by href.
 */
const KEYFRAMES = `@keyframes zsc-pop{0%{transform:scale(0.55);opacity:0}60%{transform:scale(1.1);opacity:1}100%{transform:scale(1);opacity:1}}
@keyframes zsc-spark{0%{transform:rotate(var(--zsc-a)) translateY(0) scale(0.4);opacity:0}25%{opacity:1}100%{transform:rotate(var(--zsc-a)) translateY(-30px) scale(1);opacity:0}}`

/** Deterministic burst — 8 fixed angles, no Math.random() during render. */

Installation

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

Prompt

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; optional steps 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 href nor onClick is 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-motion only the static panel remains.
  • Dismiss as a requestonDismiss is 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.

On This Page