System & states
Empty, error, loading, stale and confirm states. They decide whether a product feels finished.
82. EmptyState#
Three variants, and each one is really its own component:
| Variant | Illustration | Content |
|---|---|---|
first-run |
Isometric spot, 64px, left of the copy | "No transactions yet" + explanation + one primary action |
filtered |
None | "No transactions match these filters" + "Clear filters" |
period |
None | "No transactions between 1–30 Sep 2026" + "Widen the period" |
An illustration on the filtered variant is condescending. The user knows what happened and wants the fix.
Always exactly one next action. An empty state with no action is a dead end.
description is one line, because an empty state is read at a glance. The particulars (which three filters are active, what the import does before anything posts) go in detail, behind the ⓘ on the title.
Illustration entrance: back-to-front, 60ms stagger, 8px translate, 320ms --ease-out, total capped at 480ms, never loops. Spec.
83. ErrorState#
Couldn't load transactions. ⓘ ← the technical account, one press away
No transactions were affected. ← the financial reassurance
[Try again] [Contact support]
The reassurance is the most important line, and the only prose that stays on screen. In a financial product, an error makes people worry about their money. Saying plainly that nothing happened to it is worth more than the retry button or a paragraph about the outage.
The detail (which endpoint, which timeout, which missing rate) belongs to whoever asks for it, so it rides an InfoTip on the title instead of a third paragraph. It's an InfoTip, not a Tooltip, because it opens on press and works on touch.
Variants: network · permission · not-found · server · stale-data. Each says what happened, what it means for the user's money, and what to do next.
84. Skeleton#
Matches the exact shape, position and radii of the content it replaces. A skeleton that doesn't match makes the page jump when content lands, which is worse than no skeleton.
A slow breathe (opacity 0.6↔1, 1.6s) is allowed. No shimmer sweep: a gradient sliding across a full-page skeleton is expensive paint and the most obvious template tell there is.
85. StaleIndicator#
"Last synced 12m ago · Retry". Appears on any surface showing cached or offline data. Dims the affected values to 0.7 — wrap them in StaleValue, or pass stale to the Card that holds them.
The age stays relative past the hour ("3h ago", "2d ago") — unlike AsOf, because the age is the alarm — with the absolute stamp and its named zone on hover. The server and first paint render the date alone; the age swaps in once the client clock is available, off the shared one-interval clock.
Stale financial data shown as current is the most dangerous state in the product. This component makes it impossible to miss.
86. Toast#
Sonner, configured to our tokens. 400ms ease enter, 200ms exit, transitions not keyframes, stack offsets −0.05 scale / −16px / −0.2 opacity, swipe-dismiss at 45px or velocity > 0.11. Auto-dismiss pauses on tab hide.
Never the only channel for a critical error. Toasts are timed and dismissible; a failed payment persists inline.
87. CommandPalette#
Zero animation. Opens instantly. It's used hundreds of times a day, which makes it the canonical Tier 0 component and the clearest case of the frequency law.
Money-aware search: typing 1240 finds transactions with that amount; typing >1000 filters by magnitude; typing a date range parses it. Grouped results, recents first, full keyboard navigation.
88. Sheet#
Vaul, configured. The primary overlay on phone. A sheet enters from the thumb zone; a centered dialog puts its actions in the middle of the screen.
320ms --ease-drawer enter, 225ms exit, 1:1 finger tracking, spring.gesture release, velocity-aware dismissal, boundary damping, pointer capture, multi-touch protection. Snap points for partial heights. overscroll-behavior: contain. Footer padded to max(24px, env(safe-area-inset-bottom)).
89. HoldToConfirm#
clip-path: inset() fill, 1500ms linear press, 200ms --ease-out release, scale(0.97) on the button. Slow where the user is deciding, fast where the system is responding.
Accessible alternatives are required: Space to hold with aria-live progress at 0/50/100%, Escape to abort, and a typed-confirmation fallback. Gating a financial action behind a motor skill is an accessibility failure, so hold-to-confirm is never the only path.
90. DensityToggle#
compact / default / comfortable. Persisted per user and synced across devices. Clamped to comfortable on phone whatever the stored preference, because 32px rows with 13px text fail both touch targets and arm's-length legibility.
Sets data-density on a container; every list component reads it.