Propel

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

RungHeightTextGlyphUsed for
xs20pxcaption-md · 12px14pxInline chrome only — compact buttons, badges, chips. Ships with a synthesized 24px pointer target (WCAG 2.5.8); unavailable in the contiguous families (ButtonGroupButton, SplitButton), whose touching segments would intersect adjacent rings.
sm24pxbody-xs · 13px14pxDense rows and chips; the floor for grouped controls.
md28pxbody-xs · 13px16pxThe workhorse — most actions live here. Menu rows, list headers and toolbars sit at 28px.
lg32pxbody-sm · 14px16pxThe smallest bordered form field; emphasized actions.
xl36pxbody-sm · 14px20pxComfortable forms.
2xl40pxbody-md · 16px20pxThe largest field; hero actions.

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):

RungPaddingGapRadius
xs6px4px4px
sm8px4px6px
md10px6px6px
lg12px6px6px
xl14px8px8px
2xl16px8px8px

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 sm up is a native 24px pointer target; xs synthesizes one with an invisible expansion ring, which is also why the contiguous families (ButtonGroup, SplitButton) start at sm — their segments touch, so adjacent rings would intersect and void the exception. Toolbar also starts at sm, 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 xssm, mdlg and xl2xl, while the type is constant across smmd and lgxl; 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.

GroupRungsFamilies & notes
Buttonsxs – lgButton and IconButton.
SplitButtonsm – lgNo xs — the segments are contiguous, so the synthesized 24px target is unavailable (same rule as ButtonGroupButton).
Pillsxs – mdPillButton, IconPill.
Badgexs – sm
ComboboxChipxs – lgAlways one rung below its frame.
ButtonGroupButtonsm – mdNo xs — contiguous segments can't take the synthesized 24px target.
AnchorButtonxs – lgType and glyph only — an inline link has no control box, so a step sets the label size and the glyph, never a height, padding or radius.
Tabssm · mdvariant selects the rung, not size — contained pins sm (24px / body-xs). Underline pins md height (28px) but label type is body-sm (14px), one rung louder than md's body-xs — deliberate for flat chrome, not ladder drift.
Togglexs – mdToggle and IconToggle share the same three rungs.
Toolbarsm – mdFigma density maps compact → sm and comfortable → md, so 24px is the floor. Items sit 8px apart, so unlike ButtonGroupButton this is not a contiguous-target constraint.
NumberFieldsm – lgFrame height; steppers use numberFieldStepperSize (one rung below). No xs — halo rung.
Fieldslg – 2xlInput, OTPField, and the Field/Fieldset labels and helpers.
Pickersmd – 2xlAutocomplete, ExpandableSearch, Combobox, and the listbox rows.
Selectlg – 2xlDropped md in the Figma redesign — starts where the text Input does.
TextAreamd – 2xlTypography runs one rung calmer than a field.

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

FamilyRangeNotes
Avatar · WorkspaceAvatar16 – 64pxAn optical monogram ramp; its xs–lg (20/24/28/32) agree with the ladder's labels.
Switch14 – 18pxTrack heights, not control boxes.
Slider12 – 20pxThe size axis is thumb geometry.
CircularProgress16 / 20pxRing diameters.
Shortcut11 / 12px typeMetadata, never a control label.
ScrollArea · scrollbar-*12 / 14 / 16pxGutter width, not a control height — matches the Figma Scrollbar set.

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.