Patterns
The distilled rulebook behind every screen built on ground — surfaces, fields, selected states, table shape, sizing. Read it before building a page, not after something looks off.
Rules distilled from real usage of design-system’s components across dashboards, tables and toolbars. Apply them directly — don’t re-derive from scratch.
Tokens & theming
theme.cssis the single source for the palette — an app’sapp.cssis only@import '@firmly/design-system/theme.css';. Never hex values, token overrides, orfocus-visible:/ring-/outline-classes in app code; change theme.css once, every app updates. Exception: merchant-supplied branding (e.g. a brand-color swatch) is content, not chrome.- Contrast model: a
backgroundpage with elevatedcard/popoversurfaces (cards, headers, sidebar, overlays) on top.foreground= primary text,muted= secondary text (no separate muted-foreground token),hover= the one shared hover tint,field= fill for anything typed/picked in,primary/primary-foreground= accent,ring= focus color (same as primary). Light/dark both derive from these names vialight-dark(). - Focus is owned globally by one rule in theme.css:
:focus-visible { outline: 1px solid var(--ring); outline-offset: -1px }. It draws inside the box on purpose —surface-sheenalready puts a 1px lit rim there, so focus recolors that rim instead of adding a second shape outside it; nothing shifts or grows as focus moves. Components/apps must not add their own focus classes, and must never useoutline-nonewithout a replacement highlight (menu items usedata-highlighted:bg-hover). - That rule must stay in
@layer base.@import 'tailwindcss'puts every utility in theutilitieslayer, and unlayered CSS beats any layer regardless of specificity — so an unlayered global rule can’t be overridden by a class, andoutline-noneon a component would become silent dead code. Anything global a component may legitimately need to override belongs in a layer. - Exception: an overlay focused purely to trap focus (
PopoverContent,DialogContent,SelectContent) can suppress the outline entirely — a ring around the whole panel says nothing the panel isn’t already saying, and its controls keep their own. Usefocus-visible:outline-nonethere only. - Color transitions are global too — one theme.css rule gives every interactive element a 200ms ease transition on
color/background-color/border-color. Never addtransition-colorsin components/apps; only non-color transitions (transition-transform) belong there.
State conventions — one treatment per role, everywhere:
| State | Classes |
|---|---|
| Hover | bg-hover (+ hover:text-foreground when idle text is muted) |
Anything the user picked — checked, on, chosen, the primary action (checkbox, Toggle, ToggleGroupItem, tab, date-range endpoint, Button default, Badge default, UserMenu trigger) | bg-selected text-selected-foreground |
Current page or section (Sidebar row, SectionNav entry, NavRail icon) | bg-selected text-selected-foreground font-medium + aria-current (via navItemClass) |
| Current in-page location (pagination page) | bg-card text-foreground + aria-current |
| Current location inside an overlay (a popover’s navigation rail) | bg-hover font-medium + aria-current |
| Transient keyboard/menu highlight | bg-hover |
On/off track of a Switch | bg-hover → bg-primary (the one deliberate exception, below) |
bg-selectedis mixed from--background, not the surface under it — so a save button on the page and a pressed segment in a track beside it land on the exact same blue. Neverbg-primary,bg-primary/10orbg-primary/15for a chosen state — older spellings of the same idea, each a different blue.- Hover on an already-selected element is
hover:bg-selected-hover, never an alpha film of blue.--selected-mixis 12% light / 30% dark, so a fixedprimary/15would sit above the mark in one mode and below it in the other (dimmer in dark mode);--selected-hoversteps off--selecteditself, so it moves the same direction in both modes. - Exception:
Switchkeeps rawbg-primary— its chip-sized track is its state with no label to help, so it needs to be darker/louder than any surface to read as on/off.bg-primaryis also correct wherever blue isn’t marking a choice at all:Progressfill, the filter dot, a column-resize divider. - Never
bg-secondaryfor a selected state — invisible againstbg-background. The table’s “current location” row above splits in two because--popoverequals--card: inside a popover,bg-cardwould mark nothing, so font weight carries the state instead. - In a column nav the tint is drawn around the label, not across the column:
navItemRowClasson the anchor takes the full width so the whole row stays clickable, andnavItemClasson a child inside it carries the fill at the label’s own width. A 288px band of colour for a six-letter word claims more of the eye than the word is worth, and a column of them reads as a stack of blocks rather than a list of names. The row gives up its own:focus-visibleoutline (outline-hidden) and hands it to the child throughgroup-focus-visible, so focus and hover light the same shape — that is the replacement highlight the focus rule above requires, not an exemption from it. A horizontal nav (AppShell’s header, a marketing shell) usesnavItemClassalone: its entries are already only as wide as their labels. Sidebar’s current page takes theprimarytint; every other row (even a section whose child is current) stays plaintext-foregroundon hover-only background — two filled rows would read as two locations.NavRailshares the exact same tint rule for its active icon, including a parent icon whose flyout contains the current route (the rail has no child row to carry that state otherwise).UserMenu’s trigger carries that tint at rest too, though it’s never a “current page” — the one deliberate exception, so the account control reads as part of navigation rather than a generic hoverable row.- Nav labels are never
text-muted—mutedis for secondary/meta text (descriptions, timestamps, helper labels), and a navigable destination is none of those. Dimmed labels read as disabled controls and shift contrast inconsistently between light/dark. Reserve dimming for things that really are secondary (the email under a name inUserMenu). - Exception —
AppShell’s header areas (areaLinkClass), the one nav that dims at rest. It sits on the shell’s glass beside the wordmark, where a filled pill per area would read as four controls competing with the page, so the set trades fill for weight: every area istext-muted, the current one istext-foreground font-medium, and none of them takes a background. The dimming can’t read as disabled here because exactly one member of the set is always lit — the contrast is between siblings, not between a label and its own normal state. This is the header only; aSidebarrow and aSectionNaventry keepnavItemClass.
Color
- Page-level content — cards, and a
secondarybutton sitting directly on the page — usesbg-card, neverbg-secondary:--cardand--secondaryare different tones, and mixing them reads as a bug, not a choice. - Overlays (popover, dropdown, dialog, select) use
bg-popover— the same value asbg-cardby design; don’t override it. - Text:
text-foregroundfor primary content,text-mutedfor secondary/meta text (descriptions, timestamps, helper labels). Never a one-off gray. - Exception — a
Badgechip on abg-cardsurface usesbg-secondary, contrasting against the surface underneath. A chip directly on the page background (e.g.Brand’s status chip inAppShell’s transparent header) follows the general rule and usesbg-cardinstead —bg-secondaryis nearly invisible onbg-backgroundin light mode. - A segmented control’s track (
ToggleGrouproot,TabsList) is a field, not a card (see below) — the same well-shape problem anInputhas, on the page or inside a card alike.
Fields — the one rule that keeps contrast in sync
A field (Input, Textarea, SelectTrigger, DateRangePicker’s trigger, Checkbox, the ToggleGroup/TabsList track) never names a color — it wears bg-field surface-field, and the surface underneath decides what that resolves to. (Checkbox uses bg-field alone: at 20px there’s no room for a lit edge, nothing to step off.) Two forms only, same declaration:
| Where the field sits | What bg-field resolves to |
|---|---|
| Directly on the page background (a table toolbar, a filter bar) | --card — the first surface, lit like any panel |
Inside anything already wearing a surface (Card, popover, dialog, sheet, a [--surface:…] track) | a --field-mix step off that surface, in oklab |
Why the split: the page background is an extreme, not a surface — nothing sits below pure black to step away from, so a field there becomes the first surface (keeping the light-catching sheen, reading like the bg-card buttons beside it) instead of a step above one. Once something else already holds the surface, only the step is left, so the field becomes a hole cut into it.
Consequences (previously hand-patched per call site):
- Never set a background on a field from the call site.
bg-secondary!on anInputinside aCardwas the old fix for a field vanishing into the card — now wrong everywhere and inert too, sincesurface-fieldpaints an opaque layer overbackground-color. - A field in a new container needs no extra work as long as the container sets its surface normally (
surface-sheen, plus[--surface:var(--popover)]when it isn’t a card) — the field reads it and lands the right distance away on its own. --field-mix(6% light / 12% dark, intheme.css) is deliberately smaller than--hover-mix, sohover:bg-hoveron a field still moves it.- A segmented track is a field for the same reason an input is: items inside resolve
bg-hover/bg-selectedagainst the track, so the chosen item steps off the well it sits in, not off whatever’s behind the whole control — which is why a selected tab isbg-selected(same as a selectedToggleGroupItem), neverbg-card. - Exception:
CommandInputstays unboxed — it’s aCommandpalette’s header, transparent with aborder-b; a fill would put a second panel inside the popover. - A field must never also carry
surface-sheen— a surface derives--field-surfacefrom its own--surface, so doing both creates a self-referential cycle CSS drops entirely, leaving it unpainted.
Motion
.fade-in-load— plain opacity fade on mount. The default: every top-level template wraps its main content in this, cheap enough for dense app pages (dashboards, tables) as well as landing pages..rise-in/.rise-in-view(.rise-in-2/.rise-in-3to stagger siblings) — scroll-linked reveal (animation-timeline: view()), reserved for marketing/landing sections below the fold. Outside@supports (animation-timeline: view())it does nothing at all — content just renders — a deliberate no-JS fallback, not a bug to patch..rise-in-load— the same rise+scale look as.rise-in-view, mount-triggered instead of scroll-linked, for above-the-fold content with no scroll to drive a timeline (a hero). Pair with an inlineanimation-delayper element to stagger.flyAndScale(@firmly/design-system/composables/use-transition) — the Sveltetransition:/in:for popover-shaped overlays:Popover,Select,Tooltip,DateRangePicker.- A directional slide or a plain scrim fade that isn’t
flyAndScale’s rise+scale is fine as a rawsvelte/transition(fly/fade) — seeDialogContent’s edge-sheetflyandDialogOverlay’sfade, or a tab-panel crossfade likechannel-tabs.svelte. Don’t forceflyAndScalewhere the vocabulary doesn’t fit; don’t hand-roll@keyframeseither. scrollReveal(use:,@firmly/design-system/composables/use-scroll-reveal) — a JS reveal-on-scroll for when.rise-in’s@supportsgate would otherwise leave a target browser with no reveal at all.scrollFadeandlightSourcearen’t part of this entrance vocabulary — see Scrollable regions below forscrollFade;lightSourceis the pointer-driven ambient lightAppShellalready applies.--ease-entranceis the one entrance easing token — every primitive above already uses it; never hand-roll a cubic-bezier alongside it.- Reduced motion is handled inside each primitive already (a
prefers-reduced-motionmedia query ormatchMediacheck) — never add a second guard around them. - The global color-transition rule (see Tokens & theming above) is a separate, always-on concern, not part of this list.
Borders — don’t use them
- No
borderon cards, buttons, inputs, pickers, dialogs or sheets — a surface separates from the page by tone (bg-cardonbg-background) alone; that step needs no help. - Shadow means “floating above the page” — nothing else. Only a layer that renders over other content casts one:
shadow-mdfor popover/dropdown/select/date-picker,shadow-lgfor dialog. A card, table, toolbar or selected item never floats, so a shadow there is a lie about depth. The theme defines only--shadow-md/--shadow-lgon purpose — typingshadow-smmeans you’ve reached for a non-token: it falls back to Tailwind’s stock value (hard-coded black alpha, nolight-dark()), so it looks wrong in light mode. That’s the tell the shadow shouldn’t be there. - Divider lines inside a component (e.g.
border-bunder a search input,border-rbeside a preset list) are fine — those are functional separators, not surface borders. - Cascade trap: a call-site class like
p-0/bg-carddoesn’t reliably beat a component’s own default of the same property — source order in the stylesheet decides, not attribute order. If an override seems ignored, use!(e.g.p-1!) instead of guessing at specificity.
Sizing
- Every control in a page-level toolbar row (filter buttons, search input, date range picker, its nav arrows) is
h-9, matchingButton size="sm"— a mismatched height in a shared row is a bug, not a style choice. - Applies to a table’s footer too: rows-per-page select, pagination buttons and filter chips are all
h-9/text-sm. Any control that shares a band with another matches its height — a 30px button beside a 36px select reads as broken, however small the delta. - Give the search input a fixed width (
w-64), neverw-full: inside aflex-wraptoolbar, a full-width child forces everything after it onto a second row. - A control nested inside a
Cardheader (e.g. aToggleGroupnext to a chart title) should be visibly smaller than a page-level toolbar control — it’s secondary to the card title, not standing on its own in a toolbar.
Layout
- One toolbar row per page: entity filters + reset on the left, date range picker on the right (
justify-betweenon the row), directly above the content grid. PageHeaderowns page-level chrome — title, description,breadcrumbsnippet,actionssnippet — in one place. Don’t hand-roll a separate<Breadcrumb>block above<PageHeader>; pass it through thebreadcrumbsnippet instead.breadcrumb: only on pages nested under a parent list/section (e.g.Merchants → Luxe Fashion, where the first crumb links back) — omit it on top-level pages (Dashboard, the Merchants list itself). A crumb that just repeats the page’s own title is noise, not a smaller version of the rule.- Loading skeletons must mirror the real layout: same toolbar row (including the left/right grouping), same grid spans, same order. A skeleton with a different shape than the loaded page causes a layout jump.
Scrollable regions
- Any box that clips its content — a
CommandList, a scrolling panel, the frame’s content area — getsuse:scrollFade(@firmly/design-system/composables/use-scroll-fade). A hard edge with content behind it reads as the end of the list; a fade reads as “there is more”, the only cue a user gets that scrolling is worth doing. - It fades with a
mask-image, not a colored overlay, so it costs nothing to place on any surface and can never disagree with the background behind it. Each edge’s fade is sized by that edge’s distance from the end, so it grows and shrinks with the scroll and is simply absent at the top and bottom — no transition to tune, no fade at all when the content fits. - It watches the box and every direct child, re-synced via
MutationObserver, and paints once itself instead of waiting on the observer’s first callback — a full-height pane never resizes (only its contents do), the growing child is often not the first one, and the observer’s first callback is async, so relying on it alone renders one frame with no fade. - Pass
{ top: false }(or{ bottom: false }) where an edge is already handled by something else. The frame’s content area does this: the translucent header already sits over the top of the scroll and blurs it, so a second treatment there would just muddy it.
Popover / dropdown padding
- Outer content container: a small
p-1, notp-0(reads as cramped, content touches the rounded corners) and not the component’s defaultp-4(reads as bloated for a short list).
Tables
- Any list with search/filter/sort/pagination uses
DataTable+createDataTable(@firmly/design-system/data-table) — hand-rolling from rawTableprimitives is how the same toolbar/empty-row/skeleton/pagination got copied across three screens. RawTable/TableRow/TableCellare for static tables only (a handful of rows, no controls). - Columns are declared once as data (
{ id, accessor, label, sortable, searchable, filter, align, format, hidden }); a custom cell is a snippet named after the column id.createDataTableowns search/filter/sort/pagination state and is the only thing that talks to@tanstack/table-core— app code never imports TanStack. - Filtering is one Filters button, never a row of per-column buttons — pre-declared
FacetedFilterbuttons don’t scale past two or three columns and silently cap what’s filterable.FacetedFilteritself stays available for filtering something that isn’t a table column. - The Filters button opens a two-pane panel — a rail of filterable columns on the left, that column’s editor on the right, both visible at once (same shape as
DateRangePicker’s preset rail + calendar); a single swapping column with a back button doesn’t survive more than a couple of operators. The rail marks filtered columns with abg-primarydot; the panel foots with the composed expression so the whole query stays readable while you build it. - Operators carry a symbol plus a word —
∈ = ≠ > < ↔, anda…/…a/…a…for starts-with/ends-with/contains. The panel shows both (symbol learnable next to its word); the active-filter readout shows only the symbol (aria-hidden, words in ansr-onlyspan) — prose operators are what make a filter line long enough to wrap the toolbar. - Active filters render on their own row beneath the toolbar, never inline — three inline will wrap the toolbar regardless of control sizing. They read as a caption, not chips:
text-muted text-sm, no surface, no height, just the value intext-foregroundand a small×. A filledh-9pill would carry the weight of a control, but the only control here is the×— the Filters button is the one way back into the panel. - A column already covered by the search box (
searchable) should setfilter: falseunless you genuinely need a comparison search can’t express — “Merchant contains…” beside a search that already matches on merchant is two controls for one job, and on a server table the text filter has nowhere to be sent. - Ticking a value in a list applies immediately, so facet counts stay honest as you tick. A typed comparison waits for Apply or Enter, because a server-backed table would refetch on every keystroke.
- Column visibility (
columnToggle) and rows-per-page (pageSizeOptions) are opt-in props onDataTable; turn them on for any full-page table. - Column width has two mechanisms, not interchangeable. A column that’s always one size — an icon or actions column — gets
headClass: 'w-10', and the table keeps sizing every other column to its content. Draggable columns getresizableonDataTable, which switches the whole table totable-fixed, truncates overflowing cells, and marks every resizable head with an idle (not hover-only)bg-borderdivider on its right edge — an edge you can only find by hovering is one nobody finds. The divider turnsbg-primaryon hover and thickens while dragging;width/minWidth/maxWidthon a column then set the starting size and the limits. Don’t turnresizableon just to set one width — fixed layout means every column then needs a sensible width. - A control that lives inside a cell takes part in that cell’s accessible name, so a resize handle labelled “Resize Total column” turns every cell of the column into “Total, Resize Total column” — give the cell its own
aria-labelwhen it holds one. For the same reason an unlabelled column gets no handle at all: there is nothing to name it after, and an actions gutter is not something anyone drags. - Anything that is only a color or a shape needs a name of its own: the rail’s filtered dot is
role="img" aria-label="filtered", and a selected operator carriesaria-pressed. A state a sighted user can see and nobody else can is half a state — and it’s also the half that can’t be tested. - Server-driven tables pass
server: true+total, then feedtable.queryinto their fetch. Tables whose filters should only apply on “Apply” passcommit: trueand calltable.apply(). - Wrap tables in a plain
bg-card rounded-lg overflow-hiddencontainer — noborder, and no shadow either: a table sits on the page, it doesn’t float above it. Row dividers (TableRow’s ownborder-b) stay — that’s a content separator, not a surface edge.
Charts
- Never use a chart library’s built-in absolutely-positioned legend — it floats over the plot area and overlaps the axis. Render your own legend below the chart instead: a
<ul>of color dot + label, same pattern for every chart (seedonut-chart.svelte,map-chart.svelte). - High-cardinality series (per-state, per-SKU, anything that could be a long list) skip the legend entirely and use a hover caption instead (e.g. “Hover a region to see revenue”). A full legend list floods once the series count grows past a handful.