Skip to content

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.

Terminal window
pnpm add @agencecinq/spinbutton

Import 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"> (implicit role="spinbutton") or set role="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.

SelectorRequiredRole
<cinq-spinbutton>YesWrapper component, controls the inner <input>. Carries no ARIA state.
[data-spinbutton-input]inputYesThe focusable element. Hosts role="spinbutton" and ARIA value state.
[data-spinbutton-action="increase"]OptionalClick to increase by step. Auto-disabled at the max.
[data-spinbutton-action="decrease"]OptionalClick 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.

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 matching id on the input

The component does not check this — rely on axe-core / Lighthouse / your CI.

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.

Configured via data attributes on the host:

AttributeTypeDefaultDescription
data-spinbutton-stepnumber1Increment used by buttons and arrow keys.
data-spinbutton-delaynumber20Throttle (ms) before the spinbutton-change event is dispatched.
data-spinbutton-textJSON-{"single":"item","plural":"items"} — appended to aria-valuetext.

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.

KeyFunction
Arrow UpIncrease value by step.
Arrow DownDecrease value by step.
Page UpIncrease value by step × 5 (optional per APG).
Page DownDecrease value by step × 5 (optional per APG).
HomeJump to aria-valuemin (when defined).
EndJump to aria-valuemax (when defined).
MethodDescription
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.
EventCancelableDetailDescription
spinbutton-changeYes{ 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);
});

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.

No bounds
Min only (≥ 0)
Max only (≤ 10)

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.

Strength
Dexterity
Constitution
Intelligence
Wisdom
Charisma