# 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

| Rung | Height | Text | Glyph | Used for |
| --- | --- | --- | --- | --- |
| `xs` | `20px` | `caption-md · 12px` | `14px` | Inline 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. |
| `sm` | `24px` | `body-xs · 13px` | `14px` | Dense rows and chips; the floor for grouped controls. |
| `md` | `28px` | `body-xs · 13px` | `16px` | The workhorse — most actions live here. Menu rows, list headers and toolbars sit at 28px. |
| `lg` | `32px` | `body-sm · 14px` | `16px` | The smallest bordered form field; emphasized actions. |
| `xl` | `36px` | `body-sm · 14px` | `16px` | Comfortable forms. |
| `2xl` | `40px` | `body-md · 16px` | `20px` | The 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):

| Rung | Padding | Gap | Radius |
| --- | --- | --- | --- |
| `xs` | `6px` | `4px` | `4px` |
| `sm` | `8px` | `4px` | `6px` |
| `md` | `10px` | `6px` | `6px` |
| `lg` | `12px` | `6px` | `6px` |
| `xl` | `14px` | `6px` | `8px` |
| `2xl` | `16px` | `8px` | `8px` |

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.

| Group | Rungs | Families & notes |
| --- | --- | --- |
| `Buttons` | `xs – lg` | Button and IconButton. |
| `SplitButton` | `sm – lg` | SplitButton and IconSplitButton. No xs — the segments are contiguous, so the synthesized 24px target is unavailable (same rule as ButtonGroupButton). |
| `Pills` | `xs – md` | Pill, 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. |
| `Badge` | `xs – md` | Height, 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. |
| `ComboboxChip` | `xs – lg` | Always one rung below its frame. |
| `ButtonGroupButton` | `sm – md` | No xs — contiguous segments can't take the synthesized 24px target. |
| `AnchorButton` | `xs – lg` | Type 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. |
| `TextButton` | `md – lg` | Type 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. |
| `Tabs` | `sm · md` | variant 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. |
| `Toggle` | `xs – md` | Toggle and IconToggle share the same three rungs. |
| `Toolbar` | `sm – md` | Figma 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. |
| `NumberField` | `sm – lg` | Frame height; steppers use numberFieldStepperSize (one rung below). No xs — halo rung. |
| `Fields` | `md – 2xl` | Input, OTPField, and the Field/Fieldset labels and helpers. |
| `Pickers` | `md – 2xl` | Autocomplete, ExpandableSearch, Combobox, and the listbox rows. |
| `Select` | `md – 2xl` | Starts where the text Input does. |
| `TextArea` | `md – 2xl` | Typography 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

| Family | Range | Notes |
| --- | --- | --- |
| `Avatar · WorkspaceAvatar` | `16 – 64px` | An optical monogram ramp; its xs–lg (20/24/28/32) agree with the ladder's labels. |
| `Switch` | `14 – 18px` | Track heights, not control boxes. |
| `Slider` | `12 – 20px` | The size axis is thumb geometry. |
| `CircularProgress` | `16 / 20px` | Ring diameters. |
| `Shortcut` | `11 / 12px type` | Metadata, never a control label. |
| `ScrollArea · scrollbar-*` | `8 / 10 / 12px` | Gutter width, not a control height — propel's scrollbar scale (4 / 6 / 8px thumbs). |

## Popup widths

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.

| sizing | Width | Height cap | Used for |
| --- | --- | --- | --- |
| `auto` | `Content, 204–400px, held while open` | `Available height` | Every menu, context menu and submenu (their default), and any picker list. |
| `anchor` | `The trigger's width; grows only with its content` | `Available height` | A listbox that must cover its field — the combobox default; a page-size menu. |
| `anchor-fixed` | `Exact trigger width, constrained to available space` | `288px` | Windowed ComboboxField lists whose unseen rows cannot size the popup (combobox-only; not on menu/context menu). |
| `auto-fixed` | `Content, 204–400px, held while open` | `288px` | Windowed 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.
