Build a first-run checklist that gets a new operator to their first real action
By the end of this recipe you'll have a sidebar checklist of five first-run tasks — verify email, invite a teammate, connect a bank account, do a test sale, set tax rate — that auto-completes from app state, persists its dismissal across sessions, and celebrates only when every box is ticked.
Overview
A persistent checklist that lives in the corner of the app shell. Each item is a real task the user can click to start, and each item auto-ticks itself when the underlying app state changes.
The first 20 minutes of a new operator's session decide whether they ever come back. The job of the checklist is to point at the next concrete action — "connect a bank so payouts work" — not to walk them through a guided tour. SMB software wins by getting the user to a useful action fast; the checklist is the cheapest way to surface what "useful" means for your product.
Never show 0 / 5. The "create your account" step is already done by the time the user sees the checklist — so day one starts at 1 / 5, with momentum. Zero feels like work; one feels like progress.
Auto-detection beats a manual "Mark done" button every time. If the user invited a teammate via Settings, the checklist ticks the row on its own — no second-class "I did it" button. The only manual tick is "Dismiss this checklist", which is permanent and per-user.
Skip this recipe if your app has one job and no setup. A barcode scanner that opens straight to the scan view doesn't need a checklist — the only first action is "scan".
What you'll build
Three states: first open (1 of 5), partway through (3 of 5), and the only screen we let confetti onto — 5 of 5.
Set up Mukamba Group
1 of 5 done
Almost there
3 of 5 done
You're set up. We'll get out of your way.
Required pieces
Everything this recipe pulls from @corelithzw/react.
@corelithzw/react exports used here: Stack, Checklist, Checkbox, Button, Alert. Checklist is a thin block that renders progress + items; everything below composes it.
Step-by-step build
Model the five tasks as data, not five conditional components
The checklist is a list of Task records, each with an isDone predicate that reads from app state. The component renders the list — it never branches per task. That way adding a sixth task is one line, not one component.
No manual ticks. App state is the source of truth.
complete drives the celebration branch.
Switch to the Code tab to see the snippet.
Persist dismissal with a localStorage flag — not a server call
Dismissing the checklist is a per-user, per-device preference. A round trip to the server to write a single boolean is wasted bytes. Read once on mount, write once on dismiss, and never mention it to the API.
Per-user key so two operators on one device don't see each other's state.
restore() exists so admins can re-show the checklist after a workspace reset.
Switch to the Code tab to see the snippet.
Render the checklist as one component, with the progress at the top
Progress lives at the top so the user sees the trend before they see the list. The "n of m" counter is always n ≥ 1 — that's the opinion: a 0-of-5 checklist looks like an unread inbox.
Celebrate only at 100% — and only once
Confetti on partial progress trains the user to dismiss every micro-win. Save the celebration for complete === true, run it once, and then hide the checklist on the next render. The dismissal-on-completion is auto so users don't have to think about cleaning up.
Final composition
The whole OnboardingChecklist, assembled. Task list + auto-detection + dismissal + celebration.
Variations
Three common forks. Each is a small diff from the final composition.
Per-role checklists
Owners see banking + tax; clerks see "do a test sale". Filter TASKS by the user's role at the top of the component.
// Add a role gate to each task
const ROLE_TASKS: Record<Role, string[]> = {
owner: ['email','team','bank','tax'],
clerk: ['email','sale'],
};
const visible = TASKS.filter(t =>
ROLE_TASKS[role].includes(t.id));
Server-stored progress
Multi-device users need their progress to follow them. Move dismissal into a PATCH /me/preferences call and seed from user.prefs.
// Replace useDismiss with a hook
const { mutate } = useUserPrefs();
const dismiss = () => mutate({
checklistDismissed: true,
});
// Initial value from server-loaded user
const dismissed = user.prefs.checklistDismissed;
Inline (not dismissible)
Some apps make setup mandatory before the user can do anything else. Drop the dismiss button and gate the dashboard on complete.
// In the parent
{!complete && (
<OnboardingChecklist ... />
)}
{complete && <Dashboard />}
// Inside, drop the dismiss button
Accessibility
What this recipe takes care of for you.
- Progress is announced. The progress bar uses
role="progressbar"witharia-valuenow/aria-valuemaxso screen readers read "3 of 5 done" when focus enters the panel. - Done items are read as done. Each
Checklist.Itemusesaria-checked="true"on its disc icon, not a strikethrough alone — strikethroughs are visual only. - CTAs are real links/buttons. The "Connect" button is a
Button as="a"withhrefso screen readers describe the destination, and middle-click works. - The celebration is a live region.
role="status"+aria-live="polite"on the success alert — the announcement lands once, not on every re-render. - Dismiss is keyboard reachable. The "Hide this checklist" button is a real
Buttonin tab order, not an overlay X. - Confetti respects motion preferences. The fire-and-forget animation skips when
prefers-reduced-motion: reduceis set.