Skip to content

Spinbutton

A spinbutton restricts its value to a range of discrete numeric values. <cinq-spinbutton> provides full keyboard navigation per the WAI-ARIA Authoring Practices spinbutton pattern, auto-disables its + / − buttons at the bounds, and can mirror value changes into an optional [aria-live] region you provide in the markup.

Inspired by @19h47/spinbutton.

Terminal window
pnpm add @agencecinq/spinbutton

Import once:

import "@agencecinq/spinbutton";

Then write the WAI-ARIA markup. The focusable element is the inner <input>: that’s where ARIA value state lives. The component reads it at mount and writes back to it on every value change. The host <cinq-spinbutton> carries no ARIA state.

HTML is the source of truth. The component will not auto-set role, auto-migrate attributes, or warn about missing labels. Run an a11y linter (axe, Lighthouse) to catch invalid markup.

Use input type="number" (implicit role="spinbutton") or set role="spinbutton" explicitly on input type="text".

<cinq-spinbutton>
<button name="decrease" type="button" aria-label="Decrease" tabindex="-1">
</button>
<input
type="number"
aria-label="Spell level"
aria-valuemin="0"
aria-valuemax="10"
aria-valuenow="1"
value="1"
/>
<button name="increase" type="button" aria-label="Increase" tabindex="-1">
+
</button>
</cinq-spinbutton>

The component upgrades automatically on connectedCallback. No manual initialization is needed.

Attribute / element Required Role
<cinq-spinbutton> Yes Wrapper component, controls the inner input. Carries no ARIA state.
<input> Yes The focusable element. One input per host. Hosts role="spinbutton" and ARIA value state.
button[name="increase"] Optional Click to increase by step. Auto-disabled at the max.
button[name="decrease"] Optional Click to decrease by step. Auto-disabled at the min.
[aria-live] Optional Live region for value announcements when using formatValue. One per host. Style/hide in your CSS.

The component writes aria-valuenow and value on the input. Set aria-valuetext in the markup yourself, or assign a formatValue property to keep it in sync (and optionally mirror into an [aria-live] child). Without formatValue, the component does not touch aria-valuetext.

Per the APG spinbutton pattern, all ARIA state lives on the focusable <input>. The host carries no ARIA state.

Variant Notes
input type="number" Default. Implicit role="spinbutton".
input type="text" + role="spinbutton" Free typing without native number spinners. Add inputmode="numeric".
aria-label Accessible name on the <input> when no visible label is shown.
aria-labelledby Accessible name from a separate element via id reference.
Input only Host contains only an <input>. No button[name="increase"] or button[name="decrease"]. Keyboard and programmatic API still work.
[aria-live] Optional child inside the host. Receives textContent when formatValue is set.
aria-valuemin / aria-valuemax Each optional. Omit either bound for an open range on that side.
<input
type="text"
role="spinbutton"
inputmode="numeric"
aria-label="Encumbrance"
aria-valuemin="0"
aria-valuemax="100"
aria-valuenow="50"
value="50"
/>
<span id="rations-label">Rations (days)</span>
<cinq-spinbutton>
<button name="decrease" type="button" aria-label="Decrease rations" tabindex="-1">
</button>
<input
type="number"
aria-labelledby="rations-label"
aria-valuemin="1"
aria-valuemax="30"
aria-valuenow="7"
value="7"
/>
<button name="increase" type="button" aria-label="Increase rations" tabindex="-1">
+
</button>
</cinq-spinbutton>
<cinq-spinbutton>
<input
type="number"
aria-label="Initiative modifier"
aria-valuemin="-10"
aria-valuemax="10"
aria-valuenow="0"
value="0"
/>
</cinq-spinbutton>

Pluralisation depends on locale (English: one/other, Polish: one/few/many/other; Arabic: six categories). Do not hard-code value === 1 in a Web Component. Assign formatValue and use Intl.PluralRules (or your i18n library) in the app layer. Add one [aria-live] child inside the host when you want screen readers to hear the formatted string on every change (typical pattern: hide it with a screen-reader-only class).

<cinq-spinbutton>
<button name="decrease" type="button" aria-label="Decrease" tabindex="-1">
</button>
<input
type="number"
aria-label="Spell level"
aria-valuemin="0"
aria-valuemax="9"
aria-valuenow="1"
value="1"
/>
<button name="increase" type="button" aria-label="Increase" tabindex="-1">
+
</button>
<div aria-live="polite" aria-atomic="true" class="sr-only"></div>
</cinq-spinbutton>
const host = document.querySelector("cinq-spinbutton");
const labels = {
one: "level",
other: "levels",
};
const pluralRules = new Intl.PluralRules("en");
host.formatValue = (value) => {
const unit = labels[pluralRules.select(value)] ?? labels.other;
return `${value} ${unit}`;
};
host.setValue(host.value.now, false);

Polish needs more forms:

const labels = {
one: "poziom",
few: "poziomy",
many: "poziomów",
other: "poziomów",
};
const pluralRules = new Intl.PluralRules("pl");

When formatValue is set, assistive technologies may announce its return value instead of the raw number in aria-valuenow. Phrase the string as a complete, grammatically correct label (e.g. "5 levels", not "levels" alone).

The component finds the live region with querySelector("[aria-live]") and sets textContent to the same string as aria-valuetext. It never creates or styles that node. For a visible readout in your UI, skip sr-only or mirror aria-valuetext in a separate element.

The input must have an accessible name. Use one of:

  • aria-label="..." on the <input>
  • aria-labelledby="some-id" referencing a visible label
  • Visible label text associated with the input (via <label> or aria-labelledby)

Set tabindex="-1" on the + / − buttons. The APG pattern requires the <input> to be the only focusable element of the spinbutton. The buttons are operated either via mouse/touch or via the input’s arrow keys.

Strictly the keys defined by the APG pattern. Other keys (Arrow Left/Right, Backspace, Delete, printable characters) are intentionally left untouched so the browser’s standard text-editing behavior is preserved on the input.

Key Function
Arrow Up Increase value by step.
Arrow Down Decrease value by step.
Page Up Increase value by step × 5 (optional per APG).
Page Down Decrease value by step × 5 (optional per APG).
Home Jump to aria-valuemin (when defined).
End Jump to aria-valuemax (when defined).

Configured via data attributes on the host:

Attribute Type Default Description
data-step number 1 Increment used by buttons and arrow keys.
data-delay number 100 Minimum interval (ms) between spinbutton:change dispatches during rapid value changes. DOM updates are not throttled.
Method Description
init() Binds markup + listeners. Call destroy() first if already bound.
setValue(value, emit?) Sets the current value. Clamped to min/max. Dispatches by default.
setMin(value, emit?) Updates aria-valuemin and re-clamps the current value.
setMax(value, emit?) Updates aria-valuemax and re-clamps the current value.
increase() Adds step to the current value.
decrease() Subtracts step from the current value.
destroy() Detaches listeners when removing the element from the DOM.
Property Description
formatValue Optional (value: number) => string. When set, writes the return value to aria-valuetext and the optional [aria-live] child on every value change.
$input The inner <input>.
$increase The optional increase <button>.
$decrease The optional decrease <button>.
$live The optional [aria-live] element.
Event Cancelable Detail Description
spinbutton:change Yes value: number Throttled notification when the value changes (see data-delay).

The event is dispatched on <cinq-spinbutton> (bubbles). The typed value is committed on change (blur / Enter), not on every keystroke, so the user can freely type intermediate values outside the bounds.

import { EVENTS } from "@agencecinq/utils";
const $spinbutton = document.querySelector("cinq-spinbutton");
$spinbutton?.addEventListener(EVENTS.SPINBUTTON_CHANGE, (event) => {
console.log(event.detail.value);
});

spinbutton:change is a throttled notification fired after the value is committed to the DOM. It does not gate the update: calling event.preventDefault() does not revert the value.

const $spinbutton = document.querySelector("cinq-spinbutton");
$spinbutton.setMin(10);
$spinbutton.setMax(200);
$spinbutton.setValue(50);
$spinbutton.increase();
$spinbutton.decrease();
$spinbutton.destroy();

setValue(), setMin(), and setMax() accept an optional emit flag (default true). Pass false to update state without dispatching an event.

After mutating light DOM, call destroy() then init():

$spinbutton.destroy();
// mutate light DOM...
$spinbutton.init();

Spell level

Basic usage on a wizard's spell picker. Use the buttons or keyboard (/, Page Up/Page Down, Home/End). spinbutton:change events are logged below.

Last event: none

Camp gear & initiative

Three markup patterns from the same party sheet: rations via aria-labelledby, encumbrance on type="text" with an explicit role="spinbutton", and an initiative modifier with no + / − buttons (keyboard only).

Rations (days)

Name from the visible label through aria-labelledby.

Encumbrance (text input)

Use type="text" when you want free typing without native number UI.

Initiative (input only)

Buttons are optional. Arrow keys still adjust by step.

Spell level (i18n)

Assign formatValue so aria-valuetext reads naturally in the spell book (and mirrors into an optional [aria-live] child). Use Intl.PluralRules in your app: English has one/other, Polish adds few/many. Switch locale below to hear the difference.

aria-valuetext: 1 level

Treasury

Party gold counted in increments of 10 gp. Set data-step on the host. Buttons, arrow keys, and Page Up/Page Down (×5) all respect the same increment.

Step: 10 gp per click or arrow key

Bounds on the trail

aria-valuemin and aria-valuemax are each optional. XP can climb without a ceiling, arrows cannot go below zero, and prepared 1st-level slots cannot exceed the mage's daily allowance.

Experience points

No min or max. The − / + buttons never auto-disable.

Arrows (min 0)

Minimum only. − disables at zero.

1st-level slots (max 4)

Maximum only. + disables when all slots are filled.

Party size

Recruiting for the delve: type freely in the input. Intermediate keystrokes do not dispatch events. The value commits on Enter or blur (change). Out-of-range entries set aria-invalid="true" briefly, then clamp to min/max.

Try typing 99 recruits, then blur the field.

Committed value: 5, aria-invalid: false

Combat round

Initiative tracker with data-delay throttling spinbutton:change only. DOM updates (round number, button disabled state) stay immediate. Hold + or mash the arrow keys to compare round count vs. events logged.

Round: 1, Events dispatched: 0

Hit points

Wound tracker with bounds adjusted at runtime. Type into the inputs below to call setMin(), setMax(), and setValue() on the spinbutton while the party rests or takes damage.

Last event: none

Torches (programmatic)

Light or snuff torches from external controls. Call increase(), decrease(), or setValue() programmatically. Pass emit: false to update the DOM without dispatching an event.

Torches lit: 5, Events: 0

Shop: torches

General store line item before the delve: minimum 1, maximum stock, formatValue for screen readers, and a spinbutton:change listener to refresh the gp total.

Torches

7 gp each, 12 in stock

1 torch, 7 gp

Watch duration

Camp rest split into hours and minutes. Rollover at the edges (59 to next hour when allowed) lives in your app layer, not inside the component.

Rest duration

Hours
Minutes

1 h 30 min rest

Expedition roster

Three spinbuttons share 12 party slots on the Undermountain contract. Each setMax() is derived from what the others already claimed. Porters cannot outnumber adventurers.

Adventurers

42 gp upkeep each

Henchmen

24 gp upkeep each, max 9

Porters

No pay, one per adventurer, max 2

Party slots

3 in party, 108 gp upkeep, 9 slots open

Character sheet

Six AD&D ability scores, each bound between 3 and 18. Roll 3d6 calls setValue(), then picking a class raises the minimum on the relevant abilities through setMin() (2nd-edition requirements). Out-of-range scores are clamped automatically.

Strength
Dexterity
Constitution
Intelligence
Wisdom
Charisma