Components
Controls are built on four heights — 24, 28, 32, 40. 32 is the default for buttons; 40 is the default for the input family.
Every interactive element supports the same state set, and that set is closed: default, hover, active, focus-visible, disabled, loading, selected, error. There are no others. A control that needs a new state is a control that needs rethinking.
Buttons
Destructive actions use the secondary shell with error text plus a confirmation step — never a solid red fill. A big red "Delete" invites exactly the misclick it was meant to prevent. Button labels are verbs: Generate, Export, Regenerate — not "OK", not "Submit". Loading keeps the width and the label, swapping only the leading icon for a spinner and setting aria-busy.
The input family: white fill + stroke
The whole input family is white fill plus a 1px stroke-input stroke. White with a stroke says "you can type in here" most clearly, which frees the grey background-input for value pills and segmented tracks. State lives entirely in the stroke: hover lifts to stroke-strong, focus to brand-default, error to error. The error message goes next to the field.
Value pills: one shape, four widths
Uniformity belongs to the shape; width follows the content. All four pills share the same fill, stroke, radius, height, alignment and weight; only the width steps with the content. The visual anchor is the right edge, and the layout keeps that aligned — so a ragged left edge doesn't hurt reading, while a single digit stranded in a three-digit box is glaring. Keep the slack in each step under 13px.
| Step | Value | Made of | Used for |
|---|---|---|---|
chip-w-sm | 48px | 10 + two digits 17 + 10 | Plain integers, no unit |
chip-w | 64px | 10 + 17 + unit 26–34 | Two digits + % or mm |
chip-w-lg | 80px | 10 + three digits 25 + shared slot 32 | With a drag handle |
| Custom | 88px+ | By digit count | Six digits or more (a seed, say) |
What is uniform is the shape, not the width. The shape — white fill, stroke-input stroke, 8px radius, 32 tall, right-aligned, weight 500, tabular-nums — must match exactly; width steps with the content, with under 13px of slack in each step.
Horizontal scrubbing on numeric fields
Never use the native up/down spinners: the targets are small and the register is wrong for a tool panel. Use a 24×24 horizontal drag handle at the end instead. Hover the pills below — the unit fades out and the handle fades in.
The unit suffix and the drag handle share one slot. Giving each its own leaves a permanent 24px of white space at the right of every pill (the handle is invisible until hover), and you don't need to read the unit at the moment you're about to drag. The rest of the rules: movement under 3px counts as a click → focus and select all · Shift gives a 10× step · setPointerCapture so dragging outside the panel doesn't drop the gesture · in RTL, dragging left increases · removing the native spinner removes coarse keyboard adjustment, so Shift + ↑/↓ = ten steps must be restored · the handle is aria-hidden and out of the tab order · on touch devices the handle is always visible · no handle when the row already has a slider.
Select: a custom listbox
A native <select> popup cannot be skinned, so a slab of system UI drops out of your tool panel. Hence the custom build — and the price is repaying the entire keyboard and screen-reader contract, which is not optional. The selected state is background-active plus a checkmark: a background tint alone is too weak in a four-item list, and colour must never carry meaning by itself.
| Contract item | Requirement |
|---|---|
| Roles | role="combobox" / listbox / option, with aria-expanded、aria-selected、aria-activedescendant |
| Focus model | ARIA 1.2 select-only combobox: focus stays on the trigger throughout — no focus transfer (moving focus makes it far easier to strand focus on `body` when the popover closes) |
| Keys when closed | ↑↓ / Enter / Space / Home / End / any character → open |
| Keys when open | ↑↓ to move · Home/End to the ends · Enter/Space to select and close · Escape to close without changing the value · Tab closes · click outside closes · type-ahead by first letter |
| Returning focus | Give focus back to the trigger on close |
| Where the popover mounts | Mount to body using position:fixed — left in place it gets clipped by scroll containers and overflow:hidden . Flip upward when there isn't room, and clamp inside the viewport. In RTL, align to the trigger's right edge |
| No-script fallback | Keep a native <select> in the HTML, styled to match the trigger |
| The mobile cost | You lose the system picker, and 32px options are small for touch. If phones are a big part of your usage, fall back to the native |
Slider: three load-bearing numbers
Three numbers carry the load: a 6px track, track-off (neutral-300) for the unfilled part, and an 18px knob with a 1px stroke-strong stroke and the knob shadow. The unfilled part's 1.47:1 works because of area — the same ratio drawn as a hairline would vanish, drawn as a 6px band it reads. Filled against unfilled is 12:1, so the position is legible at a glance. The knob's stroke follows the same logic: a white circle on a light track dissolves under a shadow alone, and the knob is what carries the value's position through its shape.
Switch
Structurally this is a real <input type="checkbox">, and the track and knob are only decoration painted on top of it. Every decorative layer needs pointer-events: none so clicks land on the real input underneath, and the whole thing goes inside its <label> so the hit area covers the text. The 24px track height is exactly the minimum target size.
Segmented control and section header
Segmented control: grey track, 3px padding, 10px radius, with the active segment white plus micro. It is not a tab set and does not switch pages. Section header: the whole row is one <button aria-expanded>, with the title at the start and the chevron at the end of the row — that way titles line up with field labels on one line and chevrons line up with value pills on another. The chevron points up/down rather than left/right, because vertical directions don't mirror in RTL and that saves a special case. Only hidden and one transform change; there is no height animation.
Be stingy with helper text
Be stingy with helper text. Anything that fits in a unit suffix should not become a sentence — 20 % beats "percentage of the piece's edge length"; anything readable in the results column should not be repeated in the parameters column. Tertiary text suits a one-line hint, not a paragraph. The ideal end state is two or three genuinely constraint-bearing notes in the entire panel, like the one above — it states a production fact rather than restating the label.