Spinbutton
A spinbutton restricts its value to a range of discrete numeric values. It
provides full keyboard navigation per the
WAI-ARIA Authoring Practices spinbutton pattern,
auto-disables its + / − buttons at the bounds, and announces value changes
to screen readers via a built-in live region.
Install
Section titled “Install”pnpm add @agencecinq/spinbuttonImport once:
import "@agencecinq/spinbutton";Then write the WAI-ARIA markup. The focusable element is the <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. Use<input type="number">(implicitrole="spinbutton") or setrole="spinbutton"explicitly on<input type="text">. Run an a11y linter (axe, Lighthouse) to catch missing labels.
<cinq-spinbutton> <button data-spinbutton-action="decrease" type="button" aria-label="Decrease" tabindex="-1"> − </button>
<input type="number" aria-label="Quantity" aria-valuemin="0" aria-valuemax="10" aria-valuenow="1" value="1" />
<button data-spinbutton-action="increase" type="button" aria-label="Increase" tabindex="-1"> + </button></cinq-spinbutton>The component upgrades automatically on connectedCallback — no manual
initialization is needed.
Required DOM
Section titled “Required DOM”| Selector | Required | Role |
|---|---|---|
<cinq-spinbutton> | Yes | Wrapper component, controls the inner <input>. Carries no ARIA state. |
[data-spinbutton-input] ‖ input | Yes | The focusable element. Hosts role="spinbutton" and ARIA value state. |
[data-spinbutton-action="increase"] | Optional | Click to increase by step. Auto-disabled at the max. |
[data-spinbutton-action="decrease"] | Optional | Click to decrease by step. Auto-disabled at the min. |
The component appends a visually hidden <div aria-live="polite" aria-atomic="true">
to announce value changes. Hiding styles are applied inline — no .sr-only
utility class required from the consumer.
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"on the<input>referencing a visible label- A wrapping
<label>element - A
<label for="…">with a matchingidon the input
The component does not check this — rely on axe-core / Lighthouse / your CI.
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.
Options
Section titled “Options”Configured via data attributes on the host:
| Attribute | Type | Default | Description |
|---|---|---|---|
data-spinbutton-step | number | 1 | Increment used by buttons and arrow keys. |
data-spinbutton-delay | number | 20 | Throttle (ms) before the spinbutton-change event is dispatched. |
data-spinbutton-text | JSON | - | {"single":"item","plural":"items"} — appended to aria-valuetext. |
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). |
| Method | Description |
|---|---|
setValue(value: number, emit?: bool) | Sets the current value. Clamped to min/max. Emits by default. |
setMin(value: number, emit?: bool) | Updates aria-valuemin and re-clamps the current value. |
setMax(value: number, emit?: bool) | Updates aria-valuemax and re-clamps the current value. |
increase() | Adds step to the current value. |
decrease() | Subtracts step from the current value. |
destroy() | Removes listeners and the live region. |
Events
Section titled “Events”| Event | Cancelable | Detail | Description |
|---|---|---|---|
spinbutton-change | Yes | { value: number } | Throttled value-change notification. |
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.
const $spinbutton = document.querySelector("cinq-spinbutton");
$spinbutton?.addEventListener("spinbutton-change", (event) => { console.log(event.detail.value);});Examples
Section titled “Examples”Spell level
Pick a spell slot level. Custom singular / plural labels are announced through the live
region:
data-spinbutton-text='{"single":"level","plural":"levels"}'.
Try the keyboard: ↑/↓, Page Up/Page Down,
Home/End.
1 level
Bounded vs. unbounded
aria-valuemin and aria-valuemax are both optional. When absent,
the corresponding bound falls back to Number.MIN_SAFE_INTEGER /
Number.MAX_SAFE_INTEGER and the matching button is never auto-disabled.
Live controls
Drives a spinbutton through its public API. Type into the inputs below to call
setMin(), setMax(), and setValue() at runtime.
spinbutton-change:
20
Character sheet
Six D&D ability scores, each bound between 3 and 18. Roll 3d6 calls
setValue(); picking a class raises the minimum on the
relevant abilities through setMin() (AD&D-style requirements). Out-of-range
scores are clamped automatically.