Scales
Colour is one half of a vocabulary; this is the other. Every corner, size, shadow, speed and layer a product draws has a name here, so that a screen can be described in words rather than in pixels.
These values are not derived from a ratio. They were read off the products the line already ships — the radius that appears twenty times, the two type sizes that carry nine tenths of every screen — and then tidied. A scale invented from a formula is a scale nobody’s existing code fits.
Radius
Section titled “Radius”`md` is the control radius: inputs, buttons, list rows. Anything bigger is a surface; anything smaller sits inside something else.
--radius-xs4px--radius-sm6px--radius-md9px--radius-lg12px--radius-xl16px--radius-2xl20pxA shape nested inside another needs a smaller corner than its parent, or the
inner curve reads as wrong against the outer one. --radius-inner is that
correction, expressed rather than guessed:
.card { border-radius: var(--radius-lg); }.card > header { border-radius: var(--radius-inner) var(--radius-inner) 0 0; }Seven steps from 10px to 21px, each with the line height it is set on.
--text-2xs10px--text-xs11px--text-sm12px--text-base14px--text-lg16px--text-xl18px--text-2xl21pxbase is body text. The two steps below it are for metadata — a timestamp, a
count, a label — and the ones above are headings. There is deliberately nothing
between them: the products this scale came from had nine sizes inside four
pixels, and no reader could tell any of them apart.
Three weights. `bold` is absent because nothing in the line uses it - `semibold` is what emphasis looks like here.
--font-weight-normal400--font-weight-medium500--font-weight-semibold600Letter spacing, for the two cases that need it: the uppercase caption, and a large heading that would otherwise read as loose.
--tracking-caption0.085em--tracking-tight-0.01emThe page itself is set in base: the theme gives body that size and its line
height, so text nobody gave a class reads at the scale’s own body size rather
than the browser’s 16px.
The caption
Section titled “The caption”The small uppercase label — a field’s name, a panel’s section, a group of menu
items, a rail’s group — is one utility, caption: text-2xs, semibold,
uppercase, tracking-caption, in dim — not faint, which at 10px is below
AA in both themes (3.45:1 light, 3.16:1 dark, measured).
<legend className="caption">Type</legend><th className="caption">Released</th>It is a utility rather than a component because it is a role text plays inside
things that are already elements — a <legend>, a <th>, a <label>. The set
wrote it seven ways before it had a name, with and without a weight, so a
Field’s label and the SectionLabel above it were two weights of one thing; a
gate now refuses a caption written out by hand in a primitive, and
no-arbitrary-scale rewrites
tracking-[0.085em] in a product to tracking-caption.
The weight of an icon's line. lucide draws at 2, tuned for its own 24px; at the 14 and 16px the line draws icons at, 2 reads heavier than the text beside it.
--icon-stroke1.75The theme applies it to every lucide icon still on lucide’s default width
(svg.lucide[stroke-width='2']), in the base layer. An icon given a width of
its own keeps it - a strokeWidth at the call site, absoluteStrokeWidth, or a
stroke-* utility - and an icon from another set is not touched.
Motion
Section titled “Motion”`quick` for a colour or an opacity that should feel immediate, `base` for something that moves, `slow` for something arriving from off-screen.
--duration-quick120ms--duration-base160ms--duration-slow240ms`out` for anything the user asked for - it arrives fast and settles. `in-out` for something moving on its own.
--ease-outcubic-bezier(0.2, 0, 0, 1)--ease-in-outcubic-bezier(0.4, 0, 0.2, 1)There is no ease-in. It starts slowly, which on a control the user just
clicked reads as lag.
Each step is a class as well as a custom property: duration-quick,
duration-base, duration-slow. A bare transition-colors with no duration
takes quick, because the theme makes it the default - so a transition in a
product runs on a step whether or not anyone named one. Inside an arbitrary
transition the property is read directly:
<div className="transition-opacity duration-base ease-out" /><div className="[transition:opacity_var(--duration-quick)_var(--ease-out)]" />Until v0.37 the theme said a named duration could not be a class, and the classes the set wrote compiled to nothing - every one of those transitions ran on Tailwind’s own 150ms. The motion gate now compiles every class of motion the set writes.
All of it is cut to nothing under prefers-reduced-motion - that is handled by
the theme, and a product does not have to remember it. A loop - a spinner, a
skeleton - names its rest itself with motion-reduce:animate-none.
Elevation
Section titled “Elevation”Three steps, shown in both themes: a shadow tuned for a dark ground disappears on a light one, so each theme has its own.
--shadow-lift0 1px 2px rgb(0 0 0 / 0.4), 0 2px 6px rgb(0 0 0 / 0.22)--shadow-raise0 2px 4px rgb(0 0 0 / 0.36), 0 8px 20px rgb(0 0 0 / 0.26)--shadow-float0 4px 8px rgb(0 0 0 / 0.36), 0 18px 44px rgb(0 0 0 / 0.32)Use lift for something that has left the page by a millimetre — a hovered
row, a sticky header. raise is the working elevation of menus, popovers and
toasts. float is for the thing that takes over the screen: a modal, a command
palette.
The products this came from had one shadow and used it for all three, which is why a modal never felt further away than the menu it covered.
Stacking order
Section titled “Stacking order”What covers what. The values only mean anything relative to each other.
--z-popup10--z-sticky20--z-menu30--z-floating40--z-overlay50--z-modal60--z-palette70--z-toast80.modal { z-index: var(--z-modal); }Named layers exist because the alternative is arithmetic in the dark. A command
palette has to sit above a modal; with bare numbers that means writing 70
somewhere and hoping nobody later writes 80 for a tooltip. With names, the
order is a sentence you can read.
A toast is last on purpose: a message about what just happened has to be visible over whatever caused it.
Control height and density
Section titled “Control height and density”--row-control: 36px; /* h-control — a field, a select, a date picker */--row-control-sm: 32px;/* h-control-sm */--row-control-lg: 40px;/* h-control-lg */--row-cell: 10px; /* py-row — the space above and below a table row */h-9 used to appear in nine primitives — every field the set has — each
spelling it out, which is why Input could not be made compact without editing
Input. One decision made nine times gets one name.
What stands on the rows:
| Row | Fields | Controls |
|---|---|---|
h-control |
Input, DatePicker, and Select and Combobox at md |
Button at md |
h-control-sm |
Select and Combobox at sm |
Button at sm, a line tab of Tabs |
h-control-lg |
Select and Combobox at lg |
— |
So a button beside a field of the same size is the same height at every
density. Button at xs and the three icon sizes are deliberately off the
rows: they sit inside something — a chat line, a chip, a table cell, a title
bar — and are sized by it, with target-min keeping the hit area at the
pointer floor.
Density is an attribute on a container, not a prop on a component:
<form data-density="compact"> <!-- every field inside it -->compact and comfortable move the control height and the table row together.
A prop would have to be added to every primitive, threaded through every
wrapper, and passed at each call site — and a primitive written next year would
not have it. An attribute is inherited, so it reaches components that did not
exist when the product was built, including a product’s own, as long as they
measure in h-control like everything else.
These are plain custom properties rather than @theme tokens, and that is
load-bearing: a token declared in @theme is inlined by the compiler, so
h-control would emit height: 36px and an override on a container would have
nothing to bind to. It was written that way first and measured in a browser —
the container reported 32px while the field inside it stayed 36.
Pointer target
Section titled “Pointer target”--size-target: 24px; /* size-target, and the `target-min` utility */The floor WCAG 2.2 sets for a pointer target (2.5.8, AA). It does not move with density: a compact row is still one a hand can hit.
<button className="size-4 target-min" aria-label={t('tag.remove')}>target-min grows the hit area without moving the glyph — a pseudo-element
centred on the control, taking its size, refusing to go under the floor. The
obvious fix is to make the control 24px instead, and it is the wrong one: a
chip is small by design, and a cross a third of its height reads as a button
with a chip around it. Measured on the stand, the set had four answers to this
one question — 16, 19, 21 and 24 — and three of them failed.
A step between the steps
Section titled “A step between the steps”--spacing-hair: 3px; /* gap-hair, -top-hair */The spacing scale runs in fours. Four primitives independently reached past it for three pixels — the gap between segments of a rating and of an axis, between cells of a heatmap, and how far a tooltip’s arrow tucks under its popup. Four hands arriving at one number is a step the scale was missing, not four accidents.
For hairline gaps between things that are themselves small, where two pixels reads as touching and four as separate objects. Nothing larger belongs here.