Components / forms
Spinbutton
Building the steppers as real buttons was rejected: three tab stops for one value.
Summary
A number the reader types or nudges — quantities, dimensions, durations.
| APG pattern | Spinbutton |
| Built on | native elements |
| Status | stable |
| Since | 0.1.0 |
<Spinbutton id="width" label="Width" defaultValue={12} min={1} max={24} suffix="col" />
Anatomy
A native number input with a stepper pair beside it.
| Part | Element | Required |
|---|---|---|
control | <input> | yes |
step | <button> | yes |
steppers | <span> | yes |
Properties
| Property | Values | Default | What it does |
|---|---|---|---|
value | number | string | none | Passing this makes the input controlled. |
defaultValue | number | string | none | The starting number when the input keeps its own state. |
onValueChange | (value: number | null) => void | none | Fires with the parsed number, or null when the field is cleared. |
min | number | none | The lowest accepted value. Enforced by the platform, including on the arrow keys. |
max | number | none | The highest accepted value. |
step | number | 1 | How far one arrow key moves. It also decides what the platform treats as a valid number — a step of 1 rejects 2.5. |
invalid | boolean | none | Marks the value as failing validation. Pair it with a visible message: the red border alone fails roughly one man in twelve. |
id | string | none | Wires the label to the input. One is generated when omitted. |
States
| State | Expressed as | |
|---|---|---|
| Disabled | :disabled | |
| Focused | :focus-visible | |
| Hover | :hover | |
| Invalid | data-invalid |
Behavior
The input is a native type="number", which is the point. It already has arrow keys, Home and End, typing, locale parsing and the numeric keyboard on touch. Every custom number editor rebuilds those and most rebuild them badly.
Accessibility
The steppers are aria-hidden and out of the tab order. They duplicate an operation the input already exposes to the keyboard, and exposing them again would make a screen-reader user tab through three controls to set one value.
The suffix is part of the label, not a decoration beside it. A reader hearing "12" needs "columns".
Keyboard
| Key | Result |
|---|---|
Arrow Up / Arrow Down | Increment and decrement by step — from the native input |
Home / End | Jump to min or max |
Page Up / Page Down | Move by a larger increment |
| typing | Enters a value directly, parsed by the platform in the reader's locale |
Roles, states and properties
| Attribute | Set by |
|---|---|
aria-hidden | always true |
aria-invalid | set at render |
Focus
One tab stop: the input. The steppers are aria-hidden and out of the tab order — they duplicate the arrow keys, and exposing them would make a screen-reader user pass three controls to set one value.
Screen reader
Not recorded as verified. Stage 4 of the readiness ladder — driving each component with VoiceOver and NVDA — is unstarted across the package, and no component should claim otherwise until docs/journal/ records the session that did it.
Contrast and target size
npm run check:contrast audits every color pair this component uses across all four skins, light and dark, and fails the build on a shortfall. Two waivers are on the record in docs/audit/DEFERRED.md.
It takes the touch floor under (pointer: coarse) — the visual size is unchanged on a desktop pointer.
Tested
test/apg/batch-b.test.jsxtest/apg/partials-sweep.test.jsxtest/browser/upstream-forms-menubar.spec.mjs
Known gaps
- 7 upstream refs — no datepicker component
- 4 upstream refs — APG's own page or markup, not the pattern
Appearance
| Token | Used for |
|---|---|
--se-accent | border-color |
--se-border-hover | border-color |
--se-border-strong | border |
--se-border-width | border |
--se-control-h | block-size, inline-size |
--se-duration-instant | transition |
--se-ease-standard | transition |
--se-focus-color | outline |
--se-focus-width | outline, outline-offset |
--se-font-code | font-family |
--se-icon-sm | block-size, inline-size |
--se-leading-ui | line-height |
--se-negative | border-color |
--se-radius-md | border-end-end-radius, border-end-start-radius, border-start-end-radius, border-start-start-radius |
--se-space-3 | padding-inline |
--se-surface | background |
--se-surface-sunk | background |
--se-target-min | min-block-size |
--se-text | color |
--se-text-disabled | color |
--se-text-muted | color |
--se-text-sm | font-size |
The value uses --se-font-code for tabular figures, so a number does not change width as it changes value.
Motion
None.
Content
The label names the quantity and the suffix names the unit. If the unit only makes sense in context — col for columns — spell it in the accessible name rather than the visible one.
Usage
Use it when the number is exact and small-range. For a value where the precise figure barely matters, a Slider reads faster.
When not to use it. For a number with no meaningful step — a price the reader types freely is a Field with a text input, and steppers on it are a control nobody will press.
Notes
Building the steppers as real buttons was rejected: three tab stops for one value.