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 · 14px16pxComfortable 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 and pills keep their own tighter insets by design, and Badge takes the rung’s gap and corner but keeps its own sides):

RungPaddingGapRadius
xs6px4px4px
sm8px4px6px
md10px6px6px
lg12px6px6px
xl14px6px8px
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 from 28 through 36, 20px at 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. Bordered fields (input, select, combobox, search rows) sit two tighter at every rung, (height − 12) / 2 — 8, 10, 12, 14px from 28 to 40 — carried by --field-inset-*; their border already separates the text from what is outside it.
  • 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 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 and pills keep their own deliberately tighter insets (a chip sits inset like text, not like a field); Badge takes the rung’s gap and corner but keeps its own sides (6 / 6 / 8px, the Figma badges set); 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 – lgSplitButton and IconSplitButton. No xs — the segments are contiguous, so the synthesized 24px target is unavailable (same rule as ButtonGroupButton).
Pillsxs – mdPill, IconPill. Pill's sm keeps the 12px caption-md label under its 24px box — a chip's label is a caption and does not grow with the box — one rung quieter than sm's body-xs; deliberate, not ladder drift. The label steps to body-xs at md.
Badgexs – mdHeight, gap, glyph and corner are the rung's; the sides are the badge's own (6 / 6 / 8px, a step tighter than a button's from sm up) and the md label is body-sm (14px), the size the Figma badges set draws it at — deliberate, not ladder drift.
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.
TextButtonmd – lgType and glyph only — a text-style action 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.
Fieldsmd – 2xlInput, OTPField, and the Field/Fieldset labels and helpers.
Pickersmd – 2xlAutocomplete, ExpandableSearch, Combobox, and the listbox rows.
Selectmd – 2xlStarts 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-*8 / 10 / 12pxGutter width, not a control height — propel's scrollbar scale (4 / 6 / 8px thumbs).

Overlay panels — the dropdown menu, the context menu, submenus and the combobox list — take their width from their content, between one floor and one ceiling. There is no ladder of named widths: a panel is as wide as its rows, never narrower than 204px, never wider than 400px or the room Base UI measures beside its anchor. The floor keeps a one-word menu reading as part of the family and gives a search field room; the ceiling keeps a long row truncating instead of running across the page. A panel that must be wider than its rows is not a wider rung — it takes its trigger’s width with sizing="anchor".

A panel also holds its width while it is open: it may grow as rows arrive or a description appears, and never shrinks until it closes. A searchable list therefore keeps the width it opened at while typing filters it down to its shortest rows, instead of reflowing under the pointer with every keystroke — the one thing a fixed width used to buy.

sizingWidthHeight capUsed for
autoContent, 204–400px, held while openAvailable heightEvery menu, context menu and submenu (their default), and any picker list.
anchorThe trigger's width; grows only with its contentAvailable heightA listbox that must cover its field — the combobox default; a page-size menu.
anchor-fixedExact trigger width, constrained to available space288pxWindowed ComboboxField lists whose unseen rows cannot size the popup (combobox-only; not on menu/context menu).
auto-fixedContent, 204–400px, held while open288pxWindowed ComboboxField lists under a compact pill trigger (combobox-only; not on menu, context menu or select).

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.

The popup bounds ship the same way: --popup-width-min names the floor, --popup-width-max the ceiling, and internal/popup-width.ts mirrors them under the same test. A product theme can move every panel by restating those two values.