Blocks

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.

Preview in your theme

Loading preview…

"use client"

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

/**
 * "skipped" is deliberately a third status rather than a boolean on top of
 * "done": a user who skips "invite your team" has resolved that row, but the
 * account is NOT set up. Folding the two together is how onboarding progress
 * ends up lying about activation.
 */
export type OnboardingTaskStatus = "todo" | "done" | "skipped"

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/empty-dashboard.json

Prompt

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 doneskipped is a third status, not a boolean on top of done. It resolves the row (so the block can retire) but never moves aria-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 todo the component renders nothing and fires onComplete once, 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 to todo.
  • 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 href or a real onClick at the type level, and a blank or # href degrades to plain text rather than rendering a button with nothing behind it.

On This Page