Empty Dashboard
A first-run dashboard page — welcome copy, a setup checklist that counts done and skipped separately, a labelled skeleton of the dashboard-to-be, and a confirmed sample-data escape hatch.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/empty-dashboard.jsonPrompt
Build a React + TypeScript + Tailwind "EmptyDashboard" block (lucide-react
icons) — the whole first screen of a brand-new account, not a single empty
panel inside an otherwise working page.
Contract
- Props extend React.ComponentPropsWithoutRef<"section"> minus "title";
className merges through cn() and the rest spread on the root. forwardRef to
the section.
- tasks: OnboardingTask[] where OnboardingTask = { id, title, description?,
status: "todo" | "done" | "skipped", action, skippable?, estimateLabel? }.
Duplicate ids are dropped (first wins) — they would collide on React keys and
make Skip toggle the wrong row.
- status is a THREE-valued enum on purpose. "skipped" is not a flavour of
"done": a user who skipped "invite your team" resolved that row, but the
account is not set up. Never model it as a boolean.
- action is a discriminated union: { label, href, onClick? } | { label,
href?: undefined, onClick }. Passing neither must be a type error — a
first-run checklist whose rows do nothing is worse than no checklist.
- Callbacks: onTaskStatusChange?(id, next) (presence enables Skip / Undo skip),
onComplete?(summary: { total, completed, skipped }), onLoadSampleData?
(presence enables the sample-data block).
- Copy / layout: title, description, checklistTitle (also the progress bar's
accessible name), sampleDataReversible = true, showPreview = true.
Behavior
- PROGRESS IS DERIVED, never stored. completed = tasks with status "done";
skipped = tasks with status "skipped"; remaining = total - completed -
skipped. The counter reads "2 of 5 done · 1 skipped · 2 left" and drops the
middle clause when nothing is skipped. aria-valuenow is the DONE count and
aria-valuemax is the total, so a skipped step can never inflate the meter.
- The bar has two segments: bg-primary sized to done/total, then a muted
segment sized to skipped/total. One bar that fills for both statuses is the
exact lie this component exists to prevent.
- Retirement: when no task is left in "todo", the block returns null and calls
onComplete exactly once (ref-guarded, and re-armed if a task goes back to
"todo"). A first-run panel that outlives first run is clutter; the consumer
gets the summary and decides what fills the space.
- Skipping: Skip for now renders only for "todo" rows that are skippable and
only when onTaskStatusChange exists. A skipped row keeps its own dashed
marker, a visible "Skipped" badge, an "Undo skip" control, and the sr-only
text "Skipped" — never a strikethrough, which reads as completed.
- The skeleton preview is a picture of the future, so it must be impossible to
mistake for reporting: every shape is aria-hidden geometry with NO text at
all, and the region carries two visible markers — a "Sample layout" badge in
its header and a "Sample — not your data" watermark centred over the canvas.
Assert the invariant in tests: the preview region's text contains no digits.
- Loading sample data writes to the account, so it is a two-step action: the
trigger swaps itself for an inline confirmation naming what will be written,
who will see it, and whether it can be undone (sampleDataReversible drives
that sentence — never promise an undo the backend does not have). Escape and
Cancel return to the trigger; the confirm handler is guarded by a ref so five
clicks in one tick still produce exactly one call.
- Focus follows that swap: opening the confirmation moves focus to Cancel (the
safe option — the panel was opened by a click, so the destructive-adjacent
button must not be one Enter away), cancelling returns focus to the trigger,
and confirming moves it to the tabIndex={-1} result line. Without this the
focus lands on <body> the moment the trigger unmounts.
- Dead affordances degrade instead of lying: an href that is blank or "#" is
treated as absent, and with no onClick behind it the action renders as plain
text rather than a button that does nothing.
- Progress changes are announced through one polite role="status" region,
coalesced with a ~700ms timer so ticking three rows produces one
announcement; the first paint is the baseline, not news. Clear the timer on
unmount.
Rendering & styling
- Semantic tokens only: bg-card panels, bg-primary / text-primary-foreground
for the one primary action per row, bg-muted track with a
bg-muted-foreground/30 skipped segment, text-muted-foreground for supporting
copy, border-dashed for everything that represents "not real yet".
hover:bg-foreground/5 for quiet controls — hover:bg-accent would be a no-op
in the light theme, where accent/muted/secondary share one value.
- Structure: a section labelled by its h2, an h3 per column, and the checklist
as a real ordered list, since these are numbered setup steps — with an
explicit role="list", because Tailwind's preflight strips list-style and some
screen readers drop the list semantics with it. The progress bar is
role="progressbar" with aria-valuemin/max/now plus aria-valuetext carrying
the same sentence the sighted user reads.
- Layout: one column by default, splitting into checklist + preview at lg with
minmax(0,·) tracks and items-start, so the preview hugs its content instead
of stretching into a tall empty dashed box that reads as a failed load. Task
titles use wrap-anywhere with min-w-0 ancestors — both are needed; either one
alone still lets a long unbroken identifier push the card sideways.
- Motion: the skeleton pulse is motion-safe-gated and the bar's width
transition is motion-reduce-gated, so reduced motion is completely still
while every number and control still works.
Customization levers
- Task set: the block is driven entirely by the tasks array — add, reorder or
translate steps without touching the render tree. estimateLabel is the cheap
way to raise completion ("2 min" next to a title).
- Required steps: skippable: false removes the Skip control for steps the
product genuinely cannot proceed without.
- Retirement policy: onComplete + returning null is the default. To keep a
celebration on screen instead, render your own panel when onComplete fires;
to retire only on full completion (ignoring skips), filter skipped rows out
of the tasks you pass in.
- Density and columns: drop the preview with showPreview={false} for a narrow
sidebar placement; change the lg grid ratio for a wider or narrower preview.
- The preview canvas: swap the three placeholder groups (stat row / bar block /
record rows) for shapes that match your real dashboard, keeping the two rules
intact — no text inside the canvas, and the sample markers stay.
- Sample data: omit onLoadSampleData to hide the escape hatch entirely; set
sampleDataReversible={false} to switch the confirmation to the "cannot be
undone" wording.Concepts
- Skip is not done —
skippedis a third status, not a boolean on top ofdone. It resolves the row (so the block can retire) but never movesaria-valuenow, never fills the primary segment, and never gets a strikethrough. Folding the two together is how activation dashboards end up reporting setup that never happened. - Derived progress — every number on screen is recomputed from the task array on each render; nothing is stored, so the meter cannot drift from the rows underneath it. The same string feeds the visible counter and
aria-valuetext. - Self-retiring panel — with nothing left in
todothe component renders nothing and firesonCompleteonce, handing the page back instead of squatting on the home screen forever. The guard is a ref, so it survives re-renders and re-arms if a task returns totodo. - Labelled skeleton preview — the "what this will look like" region is aria-hidden geometry with no text at all, plus a badge and a watermark. The testable invariant is that the region contains no digits: a screenshot of an empty dashboard must never be mistakable for a report.
- Confirmed side effect — loading sample data writes to the account, so the trigger swaps itself for an inline confirmation that names what gets written, who sees it, and whether it can be undone; focus moves to the safe option, Escape backs out, and a ref guard keeps rapid clicks to a single call.
- Honest affordance — an action needs a real
hrefor a realonClickat the type level, and a blank or#href degrades to plain text rather than rendering a button with nothing behind it.
Usage Dashboard
A period-scoped usage block — headline, quota bar, cumulative burn-up curve and per-dimension breakdown all derived from one bucket array, plus a run-rate projection stated apart from actual usage.
Search Results
A four-state search results page — query echo, keyword highlighting, faceted filters whose counts are the real post-click result count, and pagination that snaps back to page 1 on every filter change.