Sizing
The six-rung control ladder — one label means one height on every component.
Every control height in Propel comes from one ladder: six rungs, 4px apart, from 20 to 40px. A
size label names a rung, not a position inside one component’s own range — so size="lg" is 32px
on a Button, on an Input, and on a listbox row, and controls at the same label always line up.
md (28px) is the anchor: the most-used action height in the product. Menu rows and list
section headers sit at 28px without exposing a size prop, and md is the comfortable toolbar
step.
The rungs
Each rung also fixes its box geometry — side padding, the icon-to-label gap, and the corner radius (applied to buttons and bordered fields; chips, pills and badges keep their own tighter insets by design):
Five relationships hold at every rung (the first three are enforced by the library’s unit tests; the last two are pinned as token data):
- Text follows the height. One composite text token per rung — 20px rows are always 12px type, 24 and 28px rows are 13px, 32 and 36px rows are 14px, 40px rows are 16px.
- Glyphs pair with the gap. One glyph per rung — 14px at the 20 and 24px rows, 16px at 28 and 32, 20px at 36 and 40 — chosen so that the icon-to-label gap and the glyph always agree (4px beside 14px, 6px beside 16px, 8px beside 20px). A control shows the same icon size whether or not it has a label, so a button and an icon button at one size match.
- Padding follows the height. Side padding is
(height − 8) / 2— 6 through 16px — so density relaxes as controls grow instead of flattening, and the total padding always lands on the 4px grid. - Radius steps twice. 4px corners at the 20px rung, 6px through 32px, 8px from 36px up — the corner keeps a steady proportion of the box instead of shrinking against it.
- Targets meet WCAG 2.5.8. Every rung from
smup is a native 24px pointer target;xssynthesizes one with an invisible expansion ring, which is also why the contiguous families (ButtonGroup, SplitButton) start atsm— their segments touch, so adjacent rings would intersect and void the exception. Toolbar also starts atsm, but from the density mapping, not the halo rule — the family table below has the full story.
Why the glyph pairs with the gap, not the type
The ladder used to carry two rules for the same number: the glyph followed the type, and the
gap followed the glyph. They cannot both hold. The gap is constant across xs–sm, md–lg
and xl–2xl, while the type is constant across sm–md and lg–xl; those intervals chain
across all six rungs, so a glyph column obeying both would have to be one value everywhere — which
three distinct gaps rule out. The shipped ladder in fact broke the gap rule at exactly two rungs.
The gap rule wins, because it is the one that keeps a control internally coherent: an icon, the space after it, and the label all scale together. Following the type instead meant the 28px row’s 6px gap sat beside a 14px icon, which the pairing reserves for a 16px one. It also retires a second rule — that an icon-only control’s glyph ran a step larger than one beside a label — which read correctly for a control seen alone and wrong for the ordinary case of two controls side by side.
The geometry applies to buttons and bordered fields — the surfaces whose box is the rung’s. Chips, pills and badges keep their own deliberately tighter insets (a chip sits inset like text, not like a field), and ButtonGroup segments pack tighter than standalone buttons by design.
Who exposes which rungs
Families expose the subset of the ladder their role calls for — a chip has no business at 40px, a text field none at 20px. Passing a rung a family doesn’t expose is a type error, never a silent clamp.
Compact chrome and bordered fields meet at md and lg (28 and 32px) — the band where a button
sits beside an autocomplete or combobox and matches it at the same label, by construction. (The
canonical text Input starts at lg, so its overlap with compact chrome is the 32px rung.)
Deliberately off the ladder
The tokens
The ladder ships as CSS custom properties — --control-height-*, --control-padding-x-*,
--control-gap-*, --control-glyph-* and --control-radius-*, one
value per rung — declared in the package’s stylesheet and mirrored by
internal/size-scale.ts, whose unit tests parse the CSS so the two can never drift. Component
cvas consume them through the var-utility syntax (h-(--control-height-md)), so a rung’s
geometry has exactly one source of truth.