Components / forms
Counter
A text input inside the card was rejected: it turned the headline number into a form field and brought back the two-control problem Spinbutton already solved once.
Summary
A count in a card, nudged with plus and minus — people at a table, servings, copies. The number is the thing on screen; typing it is rare, so there is no input.
| APG pattern | Spinbutton |
| Built on | native elements |
| Status | stable |
| Since | 0.2.0 |
<Counter label="People" defaultValue={6} min={1} hint="Every recipe scales to this number." />
Anatomy
A label, a value, a stepper pair stacked beside it, and an optional hint.
| Part | Element | Required |
|---|---|---|
hint | <span> | no |
label | <span> | yes |
row | <div> | yes |
step | <button> | yes |
steppers | <span> | yes |
value | <div> | yes |
Properties
| Property | Values | Default | What it does |
|---|---|---|---|
label | string | required | What is being counted. Shown above the value and used as the accessible name. |
hint | string | none | A line under the value — "the recipe serves 4". Read as the description. |
value | number | none | Passing this makes the counter controlled. |
defaultValue | number | none | The starting number when the counter keeps its own state. Defaults to min. |
onValueChange | (value: number) => void | none | Fires with the new number, already clamped to min and max. |
min | number | 0 | The lowest value. Home jumps here. Defaults to 0. |
max | number | none | The highest value. End jumps here. Unbounded when omitted. |
step | number | 1 | How far one press or one arrow key moves. Page Up and Page Down move ten steps. |
disabled | boolean | none | Removes the value from the tab order and greys the steppers. |
id | string | none | Prefix for the generated label and hint ids. One is generated when omitted. |
States
| State | Expressed as | |
|---|---|---|
| Pressed | :active | |
| Disabled | :disabled | |
| Focused | :focus-visible | |
| Hover | :hover | |
| Disabled | data-disabled |
Behavior
The value moves by step and never leaves [min, max]. Plus and minus disable at the ends, so the reader sees the range before hitting it. onValueChange fires only when the number changes — pressing minus at min is silent.
Uncontrolled by default. Pass value to own it; otherwise the counter keeps its own number from defaultValue, or from min.
Accessibility
One tab stop: the value. It carries role="spinbutton" with aria-valuenow, aria-valuemin and aria-valuemax, and the arrow keys — the contract Spinbutton gets from its native input, given here by hand because there is no input.
The steppers are aria-hidden and out of the tab order. They duplicate the arrow keys for the pointer. Exposing them would make a screen-reader user pass three controls to set one number.
Keyboard
| Key | Result |
|---|---|
Arrow Up / Arrow Down | Increment and decrement by step |
Home / End | Jump to min or max |
Page Up / Page Down | Move by ten steps |
Roles, states and properties
| Attribute | Set by |
|---|---|
aria-describedby | set at render |
aria-disabled | set at render |
aria-hidden | always true |
aria-labelledby | set at render |
aria-valuemax | set at render |
aria-valuemin | set at render |
aria-valuenow | set at render |
role | always spinbutton |
Focus
The value takes the ring. It is the only tab stop, so the ring lands on the number the keys will change.
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 sets no target size of its own; anything interactive inside it brings its own.
Tested
test/apg/counter.test.jsx
Known gaps
_No upstream requirement is recorded as not applicable._
Appearance
| Token | Used for |
|---|---|
--se-border | border |
--se-border-strong | border |
--se-border-width | border |
--se-control-h-sm | block-size, inline-size |
--se-duration-instant | transition |
--se-ease-standard | transition |
--se-focus-color | outline |
--se-focus-offset | outline-offset |
--se-focus-width | outline |
--se-icon-sm | block-size, inline-size |
--se-leading-body | line-height |
--se-mix-down | background |
--se-mix-shade | background |
--se-radius-lg | border-radius |
--se-radius-md | border-end-end-radius, border-end-start-radius, border-start-end-radius, border-start-start-radius |
--se-radius-sm | border-radius |
--se-space-2 | gap |
--se-space-3 | gap |
--se-space-4 | padding |
--se-surface | background |
--se-surface-sunk | background |
--se-text | color |
--se-text-disabled | color |
--se-text-muted | color |
--se-text-sm | font-size |
The value is set in tabular figures, so it does not shift as it changes. The steppers stack on the trailing side, the way Spinbutton's do, so the card reads as one control and not a toolbar.
Motion
None.
Content
The label names what is counted, in sentence case: "People", "Servings". The hint says what the number does — "Every recipe scales to this number" — not what the control is.
Usage
Use it when the count is the headline. A guest list, a serving count, a quantity the reader adjusts by a few and glances at often.
When not to use it. For a number the reader types — a width, a price — use Spinbutton inside a Field. For a value where the exact figure barely matters, a Slider.
Notes
A text input inside the card was rejected: it turned the headline number into a form field and brought back the two-control problem Spinbutton already solved once.