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

Width must be between 40 and 900 mm.

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

%
mm

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.

StepValueMade ofUsed for
chip-w-sm48px10 + two digits 17 + 10Plain integers, no unit
chip-w64px10 + 17 + unit 26–34Two digits + % or mm
chip-w-lg80px10 + three digits 25 + shared slot 32With a drag handle
Custom88px+By digit countSix 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.

mm

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

Classic joints
Wide tabs
Wave

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 itemRequirement
Rolesrole="combobox" / listbox / option, with aria-expandedaria-selectedaria-activedescendant
Focus modelARIA 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 focusGive focus back to the trigger on close
Where the popover mountsMount 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 fallbackKeep a native <select> in the HTML, styled to match the trigger
The mobile costYou 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.

The slider is the workhorse of a parameter-driven generator, and it is never used alone. The current value is always shown as a number at the end of the row — a knob position is not a value. When precision matters (step counts, seeds, dimensions) pair it with a numeric field, because you cannot type into a slider. Step and range come from the model's real constraints, not from what "feels smooth"; a value that gets silently clamped is a bug the user cannot see. The disabled state should read as "present but not adjustable", not as a control that vanished.

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

%
%
The actual kerf is set by power and focus.

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.

Esc
Search all docs · to navigate · to open