Design state
The look every web product starts from — colour, type, shape, depth, width and the small behaviours — as exact values and class patterns, with the theme as real CSS.
The craft pages say how to decide. This page is what has been decided: the values a new web product starts from, so the second screen looks like the first without anyone remembering how.
Every value here is the default. A product departs from one only with an annotated exception; a product's own brand (its face, its accent, its illustrations) sits on top and never edits the rules below.
Verified 2026-10-03 · Against the reference app for taste: a production TanStack Start app on Tailwind 4, shadcn/ui primitives,
radix-ui,tw-animate-cssand Sonner. Excerpts are adapted. Blocks titledexamples/web/…are files in this handbook.
What makes it distinctive
- Near-black on warm paper. The action colour is ink; colour is spent on status and nothing else.
- Pills wherever you press. Buttons, tabs, chips and toasts are all
rounded-full. - No outlines on surfaces. Cards and empty states have no border; depth comes from shadows tinted with ink, and neutrals are ink washed over the surface.
- One face, one root. A single typeface at a 15px root sets the density of everything.
- Nothing ragged, nothing jumps. Grids fill their last row with ghost tiles, skeletons have the page's shape, and opening a menu never moves the page.
The theme
One theme package exports one stylesheet; every app imports it and no app redefines a token (see Tailwind and shadcn/ui). The blocks below are that stylesheet, region by region.
Light by default, dark optional. The :root values are the product. .dark
on <html> is a choice a product makes, not a mode every screen must pass.
:root {
/* Surfaces: a warm near-white page, a card one step warmer, never pure white. */
--background: #fdfcfb;
--foreground: oklch(0.145 0 0);
--card: #fefdfb;
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
/* The action colour is near-black. Colour is kept for status. */
--primary: #171717;
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
/* 10px at the 15px root; sm, md and xl are derived below. Pills use rounded-full. */
--radius: 0.625rem;
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
}Tailwind reads the tokens through @theme inline, so a utility like
bg-card resolves to the variable at runtime and .dark swaps it without a
rebuild. Shadows, the face and the two widths are static, so they sit in a plain
@theme:
@theme {
/*
* One face for every word of the interface. Inter is a free stand-in: a product
* names its own face here and nowhere else. `font-mono` rides it too, so figures
* and labels never switch face; only real code (`code`, `pre`, `kbd`) is monospace.
*/
--font-sans:
"Inter", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
--font-mono: var(--font-sans);
/* Shadows on the paper are tinted with ink and carry no border with them; the
toast, a saturated pill above everything, keeps a neutral one. */
--shadow-card: 0 2px 14px -6px rgba(11, 17, 16, 0.1);
--shadow-card-hover: 0 14px 36px -12px rgba(11, 17, 16, 0.2);
--shadow-popover:
0 16px 48px -12px rgba(15, 17, 16, 0.14),
0 6px 16px -8px rgba(15, 17, 16, 0.08);
--shadow-toast: 0 18px 34px rgba(0, 0, 0, 0.16);
/* Two measures: `max-w-narrow` (720px) and `max-w-page` (1140px) at the 15px root. */
--container-narrow: 48rem;
--container-page: 76rem;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-status-success: var(--status-success);
--color-status-success-bg: var(--status-success-bg);
--color-status-success-solid: var(--status-success-solid);
--color-status-success-tile: var(--status-success-tile);
--color-status-warning: var(--status-warning);
--color-status-warning-bg: var(--status-warning-bg);
--color-status-warning-solid: var(--status-warning-solid);
--color-status-warning-tile: var(--status-warning-tile);
--color-status-danger: var(--status-danger);
--color-status-danger-bg: var(--status-danger-bg);
--color-status-danger-solid: var(--status-danger-solid);
--color-status-danger-tile: var(--status-danger-tile);
--color-status-info: var(--status-info);
--color-status-info-bg: var(--status-info-bg);
--color-status-info-solid: var(--status-info-solid);
--color-status-info-tile: var(--status-info-tile);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
}Colour
Neutrals are ink washes
A neutral fill is the foreground at a low alpha, never a grey. It works on the paper, on a card and on a tint alike, because it darkens whatever is under it rather than replacing it.
| Use | Class |
|---|---|
| Ghost tile | bg-foreground/[0.02] |
| Empty state, add tile at rest | bg-foreground/[0.025] |
| Row on hover | hover:bg-foreground/[0.035] |
| Chip at rest, add tile on hover | bg-foreground/[0.05] |
| Icon button on hover | hover:bg-foreground/[0.06] |
| Tabbed dialog header strip | bg-foreground/[0.08] |
| Chip on hover | hover:bg-foreground/[0.09] |
Quiet text follows the same idea: text-muted-foreground, and
text-muted-foreground/55 for a note that should almost disappear.
Status colours come from one generator
Status tokens come in pairs — --status-<name> for the text and the dot,
--status-<name>-bg for the chip's fill — and the pair holds 4.5:1. The pairs are
not picked one by one: one hue per status, and fixed lightness and chroma steps
shared by every hue, so the four statuses always look like one family.
/*
* Status colours come from one generator: a hue per status, and four fixed
* lightness/chroma steps shared by every hue. Change a step and every status moves
* together; add a status by adding a hue. Measured after clipping to sRGB: text on
* fill is about 6:1 for all four hues, white on solid at least 8:1, white on tile
* at least 3.3:1 (icons only, never text).
*/
:root {
--tint-text: 0.4 0.12; /* text and dot */
--tint-fill: 0.88 0.08; /* chip fill behind the text */
--tint-solid: 0.4 0.12; /* a filled surface carrying white text: the toast */
--tint-tile: 0.62 0.14; /* the icon tile on a solid surface */
--hue-success: 163;
--hue-warning: 70;
--hue-danger: 34;
--hue-info: 255;
}
.dark {
/* About 7:1 text on fill. Solid and tile keep their light values. */
--tint-text: 0.8 0.1;
--tint-fill: 0.3 0.06;
}
:root,
.dark {
--status-success: oklch(var(--tint-text) var(--hue-success));
--status-success-bg: oklch(var(--tint-fill) var(--hue-success));
--status-success-solid: oklch(var(--tint-solid) var(--hue-success));
--status-success-tile: oklch(var(--tint-tile) var(--hue-success));
--status-warning: oklch(var(--tint-text) var(--hue-warning));
--status-warning-bg: oklch(var(--tint-fill) var(--hue-warning));
--status-warning-solid: oklch(var(--tint-solid) var(--hue-warning));
--status-warning-tile: oklch(var(--tint-tile) var(--hue-warning));
--status-danger: oklch(var(--tint-text) var(--hue-danger));
--status-danger-bg: oklch(var(--tint-fill) var(--hue-danger));
--status-danger-solid: oklch(var(--tint-solid) var(--hue-danger));
--status-danger-tile: oklch(var(--tint-tile) var(--hue-danger));
--status-info: oklch(var(--tint-text) var(--hue-info));
--status-info-bg: oklch(var(--tint-fill) var(--hue-info));
--status-info-solid: oklch(var(--tint-solid) var(--hue-info));
--status-info-tile: oklch(var(--tint-tile) var(--hue-info));
}The steps are the reference app's own tint generator, which colours its cards the same way:
const BG_L = 0.88;
const BG_C = 0.08;
const FG_L = 0.4;
const FG_C = 0.12;
const softTint = (hue: number, bgC = BG_C, fgC = FG_C) => ({
bg: `oklch(${BG_L} ${bgC} ${hue})`,
fg: `oklch(${FG_L} ${fgC} ${hue})`,
});Adapted from a production app. The -solid and -tile steps exist for the
toast, the one surface that is filled with a status.
<span className="inline-flex items-center gap-1.5 rounded-full bg-status-success-bg px-2.5 py-0.5 font-medium text-status-success text-xs">
<span aria-hidden className="size-1.5 rounded-full bg-status-success" />
Approved
</span>Type
One face for every word of the interface. Display, numbers, labels and body
are the same family at different sizes and weights; only real code is
monospace. The theme names Inter as a free stand-in; a product names its own face
in --font-sans and nowhere else.
The root is 15px. The app is rem-based, so the root is the one density knob: it scales type, control heights and spacing together. Every rem value on this page is at that root.
@layer base {
html {
/* The one density knob: the app is rem-based, so this scales type, control
heights and spacing together. */
font-size: 15px;
/* Reserve the scrollbar's width for good, so locking scroll under a dialog or
a menu never shifts the page sideways. */
scrollbar-gutter: stable;
}
body {
@apply m-0 bg-background text-foreground antialiased;
}
* {
@apply border-border outline-ring/50;
}
code,
pre,
kbd {
font-family:
ui-monospace, Menlo, Monaco, Consolas, "Courier New", monospace;
}
/* Preflight drops the hand cursor from buttons, and role-based controls never
had it. Restore it on everything clickable. */
button,
summary,
label[for],
select,
a[href],
[role="button"],
[role="link"],
[role="tab"],
[role="menuitem"],
[role="menuitemradio"],
[role="menuitemcheckbox"],
[role="option"],
[role="switch"],
input[type="checkbox"],
input[type="radio"],
input[type="submit"],
input[type="button"],
input[type="reset"],
input[type="file"] {
@apply cursor-pointer;
}
/* Last, so it wins on equal specificity: a disabled control never looks clickable. */
button:disabled,
select:disabled,
input:disabled,
[aria-disabled="true"],
[data-disabled] {
@apply cursor-not-allowed;
}
}The ladder
| Role | Class |
|---|---|
| Page title | font-semibold text-4xl tracking-tight |
| Page lead | mt-2 text-base text-muted-foreground leading-relaxed |
| Section title | font-semibold text-2xl tracking-tight |
| Dialog title | font-semibold text-xl tracking-tight |
| Row name | font-medium text-lg |
| Body | text-base |
| Row detail, description | text-sm text-muted-foreground |
| Field label | font-medium text-sm leading-none |
| Menu item | font-medium text-[0.9375rem] |
| Overline | font-semibold text-xs uppercase tracking-wider |
| Helper, error, tooltip | text-xs |
Titles are semibold, controls and labels medium, reading text regular.
Figures that change or line up are tabular-nums: counts, percentages,
amounts, dates in a column. On the one face, never by switching to a monospace.
<span className="shrink-0 text-muted-foreground text-xs tabular-nums">{progress}%</span>Shape and depth
Pills for what you press and what you read as a label: buttons, tabs, chips,
toasts, row menus. Rounded rectangles for what holds content: rounded-xl
for section cards, rounded-2xl for grid tiles and rows, rounded-3xl for an
empty state. Fields and menu items stay rounded-md and rounded-sm.
Cards have no border. A card is a surface on the paper, lifted by an ink-tinted shadow; the hover deepens the shadow and nothing else changes:
<div className="rounded-2xl bg-card shadow-card transition-shadow duration-300 hover:shadow-card-hover">| Token | Value | Where |
|---|---|---|
shadow-card | 0 2px 14px -6px rgba(11,17,16,0.10) | every card at rest |
shadow-card-hover | 0 14px 36px -12px rgba(11,17,16,0.20) | a card that opens something, on hover |
shadow-popover | 0 16px 48px -12px rgba(15,17,16,0.14), 0 6px 16px -8px rgba(15,17,16,0.08) | menus and popovers |
shadow-toast | 0 18px 34px rgba(0,0,0,0.16) | the toast |
A shadow on the paper is never pure black: the ink tint is what keeps it from looking dirty. The toast, a saturated pill above everything, is the one neutral shadow.
Width
Two measures, and that is the budget.
| Measure | Value | For |
|---|---|---|
max-w-narrow | 48rem (720px) | sign-in, account, settings: one column of reading |
max-w-page | 76rem (1140px) | working pages: registers, detail screens, card grids |
The page shell owns the gutters as well as the width:
export const PAGE_CONTAINER = "mx-auto w-full max-w-page px-5 sm:px-8 lg:px-12";The card grid
Cards keep one width and the grid wraps them, at most four to a row: w-60
tiles (15rem), gap-6, left-aligned, and the row capped at four tiles so a page
can align its header to the cards' right edge.
The last row is completed with ghost tiles — inert, aria-hidden, in the
faintest wash — so a grid of five never looks like a grid of eight with three
missing. The create tile counts as a tile.
const PER_ROW = 4;
const CARD_WIDTH_REM = 15;
const GAP_REM = 1.5; // gap-6
export const cardGridRowMaxWidth = `calc(${PER_ROW * CARD_WIDTH_REM + (PER_ROW - 1) * GAP_REM}rem + 1px)`;
export function CardGrid({ add, children }: { add?: ReactNode; children?: ReactNode }) {
const tileCount = Children.count(children) + (add ? 1 : 0);
const emptySlots = (PER_ROW - (tileCount % PER_ROW)) % PER_ROW;
return (
<div className="flex flex-wrap justify-start gap-6" style={{ maxWidth: cardGridRowMaxWidth }}>
{children}
{add}
{Array.from({ length: emptySlots }).map((_, i) => (
<div key={`ghost-${i}`} aria-hidden className="w-60 rounded-2xl bg-foreground/[0.02]" />
))}
</div>
);
}Adapted from a production app. The create tile is a wash, not a card:
min-h-[17rem] rounded-2xl bg-foreground/[0.025] text-muted-foreground/50 hover:bg-foreground/[0.05], with a size-14 plus in the thin weight.
Controls
Buttons
Every button is a pill. Sizes are fixed heights; an icon inside a button is 16px unless it carries its own size (see iconography).
const buttonVariants = cva(
"inline-flex shrink-0 items-center justify-center gap-2 rounded-full text-sm font-medium whitespace-nowrap outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive: "bg-destructive text-white hover:bg-destructive/90",
outline: "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
xs: "h-6 gap-1 px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-8 gap-1.5 px-3 text-sm has-[>svg]:px-2.5",
lg: "h-10 px-5 text-base has-[>svg]:px-4",
icon: "size-9",
"icon-xs": "size-6 [&_svg:not([class*='size-'])]:size-3",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: { variant: "default", size: "default" },
},
);Adapted from a production app, with its dark: variants left out. The
reference uses transition-all; name the properties instead
(transition-[color,background-color,border-color,box-shadow]), as
motion and polish requires.
Fields
A field is h-10: 2.5rem, a step taller than the default button. Label above,
help or error below, never both.
| Part | Class |
|---|---|
| Stack | space-y-2 |
| Label | flex items-center gap-1 font-medium text-sm leading-none |
| Required mark | <span aria-hidden className="text-destructive">*</span> plus <span className="sr-only">(required)</span> |
| Input | h-10 w-full rounded-md border border-input bg-transparent px-3.5 py-2 text-base shadow-xs |
| Focus | focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 |
| Invalid | aria-invalid:border-destructive aria-invalid:ring-destructive/20 |
| Help | text-muted-foreground text-xs, linked by aria-describedby |
| Error | text-destructive text-xs, replaces the help, shown once the field is touched |
Tabs
Tabs are pills that recede until chosen. The inactive tab is muted text on nothing; the active one is an elevated pill in the background colour.
// list
"inline-flex h-9 w-fit items-center justify-center gap-2 text-muted-foreground"
// trigger
"inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-full border border-transparent bg-transparent px-2 py-1 font-medium text-muted-foreground text-sm transition-[color,background-color,box-shadow,border-color] duration-200 ease-out hover:text-foreground"
// active
"data-[state=active]:border-input data-[state=active]:bg-background data-[state=active]:font-semibold data-[state=active]:text-foreground data-[state=active]:shadow-sm"Menus
A menu is a rounded card that grows from its trigger.
| Part | Class |
|---|---|
| Content | min-w-[8rem] rounded-xl border bg-popover p-1.5 shadow-popover origin-(--radix-dropdown-menu-content-transform-origin) |
| Motion | data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95, and the reverse on close |
| Item | gap-2.5 rounded-sm px-2.5 py-2.5 font-medium text-[0.9375rem] focus:bg-accent |
| Item icon | size-[1.125rem], text-muted-foreground unless the icon sets its own colour |
| Destructive item | text-destructive focus:bg-destructive/10, icon included |
A row's menu trigger is a size-8 rounded-full icon button, hidden until the row
is hovered or focused: opacity-0 group-hover:opacity-100 focus-visible:opacity-100 data-[state=open]:opacity-100.
Tooltips
Tooltips open at once: delayDuration={0}. A tooltip here labels an icon-only
control, and waiting for it is waiting for the control's name. This overrides the
first-hover delay in
motion and polish
for these labels. The tooltip is ink on paper: rounded-md bg-foreground px-3 py-1.5 text-background text-xs.
Rows
A row in a list of people or things: flex items-center gap-4 rounded-2xl px-3 py-4 transition-colors duration-500 hover:bg-foreground/[0.035], a size-12
avatar, the name in font-medium text-lg, the detail beneath in text-sm text-muted-foreground, a pill badge (rounded-full px-2.5 py-0.5 font-medium text-xs) beside the name, and the menu trigger last.
Dialogs
Every dialog is built by one builder, never assembled from raw
DialogContent. It owns the shape every dialog shares, so the look cannot drift:
- The header is the title alone:
px-6 pt-6,text-xl tracking-tight, on aborder-b bg-muted/50 pb-5bar. A supporting sentence is the first line of the body (text-muted-foreground text-sm leading-relaxed), never a subtitle. - The body is a
<form>:space-y-5 px-6 pt-5 pb-6, in a dialogsm:max-w-mdwide withgap-0 overflow-hidden p-0. - Actions are a list, and the last one is the primary submit. It is the
default variant with
type="submit", so Enter submits; earlier actions areghostbuttons. The footer aligns them right (sm:justify-end), and dialog actions use thesmsize. - An action in flight disables itself and may swap in a pending label.
- Tabs are declared, not composed. Two or more tabs render as pills beside the
title, on a darker
bg-foreground/[0.08] pb-4strip withpr-8to clear the close button; one tab renders flat. Each tab may carry its own actions, and the builder returns to the first tab when the dialog closes.
function ActionRow({ actions }: { actions: DialogAction[] }) {
return (
<>
{actions.map((action, i) => {
const isLast = i === actions.length - 1;
const isSubmit = (action.type ?? (isLast ? "submit" : "button")) === "submit";
return (
<Button
key={i}
type={isSubmit ? "submit" : "button"}
variant={action.variant ?? (isLast ? "default" : "ghost")}
disabled={action.disabled || action.pending}
onClick={isSubmit ? undefined : action.onClick}
>
{action.pending ? (action.pendingLabel ?? action.label) : action.label}
</Button>
);
})}
</>
);
}Adapted from a production app.
Toasts
A toast is a pill filled with its status, raised through one notify seam
with a title and a description, both required.
| Part | Value |
|---|---|
| Placement | bottom right, 32px from the edges; 16px on a phone |
| Viewport | min(42rem, calc(100vw - 2rem)) |
| Pill | inline-flex w-fit items-center gap-3.5 rounded-full px-4 py-3 text-white shadow-toast, on bg-status-<name>-solid |
| Icon tile | size-11 rounded-full, on bg-status-<name>-tile, glyph size-5 in the bold weight |
| Title | font-semibold text-sm leading-tight |
| Description | mt-0.5 text-[0.82rem] leading-tight text-white/85 |
| Dismiss | size-11 rounded-full border border-white/18 hover:bg-white/8 |
The toaster keeps only position, stacking, the live region and the motion; its own chrome is stripped so the pill is the only thing drawn:
<Sonner
position="bottom-right"
offset={{ right: 32, bottom: 32 }}
mobileOffset={{ right: 16, bottom: 16, left: 16 }}
style={{ "--width": "min(42rem, calc(100vw - 2rem))" } as CSSProperties}
toastOptions={{
classNames: {
toast: "group !w-fit max-w-[calc(100vw-2rem)] !border-0 !bg-transparent !p-0 !shadow-none",
},
}}
/>Adapted from a production app, whose pill colours were literal hexes; here they are the status tokens.
Empty states and loading
An empty state is a wash, not a frame. No border, no shadow:
<div className="flex flex-col items-center justify-center rounded-3xl bg-foreground/[0.025] px-6 py-16 text-center">
<span className="flex size-14 items-center justify-center rounded-2xl bg-primary/10 text-primary [&>svg]:size-7">
{icon}
</span>
<h2 className="mt-5 font-semibold text-xl tracking-tight">{title}</h2>
<p className="mt-2 max-w-md text-muted-foreground leading-relaxed">{description}</p>
<div className="mt-6">{action}</div>
</div>Inside a section whose own chrome already carries the heading, the compact shape
is one line and an optional action on a small wash, never an outline:
rounded-xl bg-foreground/[0.025] px-4 py-5 text-center. Which shape a surface
takes is empty states.
A skeleton has the shape of the page it stands in for: the same container, the
same header rows, the same grid columns, the same aspect ratios — so the content
arrives with no shift. Each block is animate-pulse rounded-md bg-accent; a pill
is a rounded-full block, an avatar a rounded-full square. The whole skeleton is
an <output aria-busy="true" aria-label="Loading …">, and it lives in its own
module that imports nothing from the server.
Behaviour
Clickable things show the hand. Tailwind's preflight drops it from buttons and
role-based controls never had it; the base layer above restores it, and a
disabled control shows not-allowed.
Opening a menu never moves the page. scrollbar-gutter: stable reserves the
scrollbar's width for good; the scroll lock's own compensation then counts it
twice, so the theme cancels it:
/*
* Companion to `scrollbar-gutter: stable`. Modal overlays lock scroll with
* react-remove-scroll, which adds a right margin the width of the scrollbar it
* hides. The gutter already reserves that width, so the margin counts it twice and
* the page jumps. Cancel it; the gutter alone keeps the layout still.
*/
body[data-scroll-locked] {
/* biome-ignore lint/complexity/noImportantStyles: react-remove-scroll sets this margin with `!important`. */
margin-right: 0 !important;
}Less motion means none. Someone who asked the system for reduced motion gets every animation and transition near-instant, set once for the whole app:
/*
* Someone who asked the system for less motion gets near-instant animations and
* transitions everywhere, not only on the entrances gated with `motion-safe:`.
*/
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
/* biome-ignore-start lint/complexity/noImportantStyles: the reset must win over every utility and third-party style. */
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
/* biome-ignore-end lint/complexity/noImportantStyles: end of the reduced-motion reset. */
}
}Content enters once. Groups of content fade up 12px over 600ms on
cubic-bezier(0.16, 1, 0.3, 1), 70ms apart, the first time they scroll into
view — on mount for what is above the fold. Rows in a list stagger by 45ms
(animate-in fade-in-0 slide-in-from-bottom-2). An entrance is never replayed on
a repeated interaction.
Icons
Phosphor, in the reference app's stroke weights. Phosphor draws on a 256 grid, so its weights land on the reference's strokes at 24px exactly:
| Weight | Stroke at 24px | For |
|---|---|---|
regular | 1.5 | everything, by default |
bold | 2.25 | toast glyphs and dismiss, disclosure chevrons |
thin | 0.75 | the large plus on a create tile |
Sizing and imports are iconography.
Never
- A grey hex for a neutral fill — use an ink wash.
- A border on a card or an empty state.
- A square button or a square tab.
- A second typeface, or a monospace to line figures up.
- A pure black shadow on a paper surface.
- A third content width.
- A dialog header with a subtitle, or a dialog built from raw
DialogContent. - A toast with a literal colour, or with only a title.
- A tooltip provider per tooltip.