Components / Empty State
Empty State
The zero-items state of a surface, list, or grid: no posts scheduled yet, no accounts connected. Says what belongs here and gives one way to fill it.
import { EmptyState } from '@layers/ui/empty-state'Rendered stock
House rules
- `onMedia` when the state sits on a fixed MEDIA plate (a black video backdrop, a builder card interior) rather than a themed surface. The semantic tokens assume a themed background, so the medallion's `bg-muted` renders very nearly invisible on `#000`, especially in dark theme; `onMedia` switches it to a translucent-white treatment that reads on any plate. Pair it with white text on the root — the plate is theme-stable in both modes, so literals are correct there.
- `onMedia` restyles the ACTION too, and that half matters more than the medallion. The default Button is bg-primary, which in light theme is rgb(23 23 25) — on the black plate that is ~1.1:1, so the label reads and the button SHAPE vanishes: a pill-sized hole nobody can tell is pressable. onMedia swaps it for a white fill with the plate’s own black as ink, 21:1 in both directions.
- An action on a media plate is often inside a click target — the builder cards wrap the whole plate in an onClick. The Button passes onClick straight through with no stopPropagation, so the host must stop the bubble itself or Retry both refetches AND navigates. That belongs at the call site, not in this component: the containing click handler is the host surface’s arrangement.
- Empty and failed are BOTH valid here; dishonest is not (founder ruling amended 2026-07-21). The ban is on "No posts yet" when the request actually failed, not on the component. A failure names itself ("Couldn't load videos"), takes icon={AlertCircle}, and offers a Retry action so the user has a way out.
- Loading is never an EmptyState. A pending request renders Skeleton rows shaped like the content that is coming.
- A failure picks its variant by blast radius, same as an empty: one that takes down the whole surface is page, one that only kills a card body or table is section. Retry is a normal button, never destructive — red is for dangerous actions, not for recovery.
- Pick the variant by host, not by taste. page fills and centers the CanvasSheet content area (the whole surface is empty); section sizes to its content where rows would be (a table, a region under its label); card is the body of a Card; inline is a small slot in a dense composition.
- Inside a Card, use variant="card", not section (2026-07-31). The Card already pays --card-spacing, so section adds 32px horizontal and 48px vertical ON TOP of it — the state floats in ~56px of gutter and doubles the card's height. card drops the horizontal padding entirely because the Card supplies it.
- The variant prop defaults to section when omitted — so a whole-surface empty must OPT IN to variant="page". Omitting variant on a subpage's only content is the common drift: it renders top-aligned instead of centered in the sheet.
- A filter-empty is not a true-empty. Rows exist and the filters exclude them, so it stays section where the rows would be. Only "there is nothing at all" earns page.
- Pass `filtered` whenever a filter, search or scope is active (2026-07-31). It swaps the whole state — "No LinkedIn accounts match this filter" with a Clear filter action — and DROPS the caller's action, because offering "Connect an account" to someone filtering for LinkedIn answers a question they did not ask. Telling a user with thirty accounts that they have none, then offering to make a thirty-first, is the failure this prevents.
- No dashed border and no hand-rolled centering wrapper (founder ruling 2026-07-21). An empty state is alone in its space and needs no outline to declare its extent; page centers itself, so a call-site flex min-h-0 flex-1 items-center wrapper is now a bug. CanvasSheetBody is a flex column by default to make that work.
- The icon is a medallion the component draws: a muted circle with the glyph in muted-foreground. Pass the LucideIcon and nothing else. Platform and brand marks come from PlatformIcon, never Lucide.
- Same state, same icon (founder ruling 2026-07-21). The glyph names what is MISSING, not the surface it sits on: every "No social accounts connected" state uses Link2, whether it renders on Schedule, Live Posts, or Comments. A per-page glyph makes one state look like three.
- Siblings agree. If a true-empty in a file carries an icon, its filter-empty sibling does too. Half-iconed sets are the most common drift.
- At most one action, at the default 36px size. lg and xl are for modal footers and full-width auth CTAs, never here.
- An action that GOES somewhere passes `render` (an anchor, a router Link); one that DOES something passes `onClick`. The prop union allows exactly one. A destination reached by a click handler is a button that happens to navigate: no href, so no middle-click, no cmd-click, no "open in new tab", no address on hover, and nothing for a screen reader to announce as a link.
- The action label names the outcome, never the destination. "Connect ads to get started", not "Go to Ads"; "Set up the Events SDK", not "View Setup Instructions". A label that just names a route makes the user do the translating.
- The action label never restates the title. If the title is "Managed UGC isn't active yet", the button is "Activate Managed UGC" — the title states the state, the button names the verb.
- Titles are sentence case and state the state: "No content found", never "No Content Found".
- The description earns its place or it is omitted (signal over noise). A description that paraphrases the title is noise.
- Copy is plain product language: no industry jargon, no em dashes, and the verb is always "post", never "publish".