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.

Rendered from the package · open on its own

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 patternSpinbutton
Built onnative elements
Statusstable
Since0.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.

PartElementRequired
hint<span>no
label<span>yes
row<div>yes
step<button>yes
steppers<span>yes
value<div>yes

Properties

PropertyValuesDefaultWhat it does
labelstringrequiredWhat is being counted. Shown above the value and used as the accessible name.
hintstringnoneA line under the value — "the recipe serves 4". Read as the description.
valuenumbernonePassing this makes the counter controlled.
defaultValuenumbernoneThe starting number when the counter keeps its own state. Defaults to min.
onValueChange(value: number) => voidnoneFires with the new number, already clamped to min and max.
minnumber0The lowest value. Home jumps here. Defaults to 0.
maxnumbernoneThe highest value. End jumps here. Unbounded when omitted.
stepnumber1How far one press or one arrow key moves. Page Up and Page Down move ten steps.
disabledbooleannoneRemoves the value from the tab order and greys the steppers.
idstringnonePrefix for the generated label and hint ids. One is generated when omitted.

States

StateExpressed as
Pressed:active
Disabled:disabled
Focused:focus-visible
Hover:hover
Disableddata-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

KeyResult
Arrow Up / Arrow DownIncrement and decrement by step
Home / EndJump to min or max
Page Up / Page DownMove by ten steps

Roles, states and properties

AttributeSet by
aria-describedbyset at render
aria-disabledset at render
aria-hiddenalways true
aria-labelledbyset at render
aria-valuemaxset at render
aria-valueminset at render
aria-valuenowset at render
rolealways 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

TokenUsed for
--se-borderborder
--se-border-strongborder
--se-border-widthborder
--se-control-h-smblock-size, inline-size
--se-duration-instanttransition
--se-ease-standardtransition
--se-focus-coloroutline
--se-focus-offsetoutline-offset
--se-focus-widthoutline
--se-icon-smblock-size, inline-size
--se-leading-bodyline-height
--se-mix-downbackground
--se-mix-shadebackground
--se-radius-lgborder-radius
--se-radius-mdborder-end-end-radius, border-end-start-radius, border-start-end-radius, border-start-start-radius
--se-radius-smborder-radius
--se-space-2gap
--se-space-3gap
--se-space-4padding
--se-surfacebackground
--se-surface-sunkbackground
--se-textcolor
--se-text-disabledcolor
--se-text-mutedcolor
--se-text-smfont-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.