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.
Install
Section titled “Install”pnpm add @agencecinq/spinbuttonImport 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.
Required markup
Section titled “Required markup”| 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.
Markup variants
Section titled “Markup variants”| 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"
Section titled “input type="text"”<input type="text" role="spinbutton" inputmode="numeric" aria-label="Encumbrance" aria-valuemin="0" aria-valuemax="100" aria-valuenow="50" value="50"/>aria-labelledby
Section titled “aria-labelledby”<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>Input only (no buttons)
Section titled “Input only (no buttons)”<cinq-spinbutton> <input type="number" aria-label="Initiative modifier" aria-valuemin="-10" aria-valuemax="10" aria-valuenow="0" value="0" /></cinq-spinbutton>formatValue and plural rules
Section titled “formatValue and plural rules”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.
Accessibility
Section titled “Accessibility”Accessible label is required
Section titled “Accessible label is required”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>oraria-labelledby)
Buttons must be out of the tab order
Section titled “Buttons must be out of the tab order”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.
Keyboard support
Section titled “Keyboard support”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). |
Attributes
Section titled “Attributes”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. |
Methods
Section titled “Methods”| 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. |
Properties
Section titled “Properties”| 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. |
Events
Section titled “Events”| 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.
Programmatic API
Section titled “Programmatic API”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();Examples
Section titled “Examples”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:
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).
Name from the visible label through aria-labelledby.
Use type="text" when you want free typing without native number UI.
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:
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.
No min or max. The − / + buttons never auto-disable.
Minimum only. − disables at zero.
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: , aria-invalid:
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: , Events dispatched:
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:
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: , Events:
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.
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.
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.