Raqam
Guides

Accessibility

WAI-ARIA spinbutton role, keyboard navigation, and screen reader behaviour.

raqam implements the WAI-ARIA spinbutton pattern. All ARIA attributes are generated automatically — you don't need to add them manually.

ARIA attributes

Input element

AttributeValueNotes
role"spinbutton"Always set
aria-valuenownumberValueCurrent numeric value
aria-valueminminValueOnly set when minValue is provided
aria-valuemaxmaxValueOnly set when maxValue is provided
aria-valuetextformatted stringLocalized display value for screen readers
aria-invalidboolean true when invalidSet by validate callback or allowOutOfRange
aria-disabledtrue when disabled
aria-readonlytrue when readOnly
aria-requiredtrue when requiredPass required prop
aria-labelledbyAuto-wired from <NumberField.Label>
aria-describedbydescription element idAuto-wired from a mounted <NumberField.Description>, merged with any aria-describedby you pass on <NumberField.Input> — see Associating help text
aria-errormessageerror element idAuto-wired to <NumberField.ErrorMessage> when the field is invalid
inputMode"decimal"Surfaces the numeric keyboard on mobile
type"text"Always text (not number) to avoid browser UI conflicts
autoComplete"off"Prevents autocomplete interference

Button elements

Increment and decrement buttons receive:

  • aria-label: "Increase" / "Decrease". For i18n, the preferred hook is the incrementLabel / decrementLabel props on <NumberField.Root> (defaults "Increase" / "Decrease"). You can still override per-button by passing your own aria-label on <NumberField.Increment> / <NumberField.Decrement> — it takes precedence over the default.
  • tabIndex={-1}: Buttons are intentionally outside the Tab order
  • disabled: When at minValue/maxValue or when the field is disabled

The scrub area is labelled via <NumberField.ScrubArea label="…"> (default "Scrub to change value").

Keyboard navigation

KeyAction
Increment by step
Decrement by step
Shift + ↑Increment by largeStep (default step × 10)
Shift + ↓Decrement by largeStep
Ctrl/Cmd + ↑Increment by smallStep (default step × 0.1)
Ctrl/Cmd + ↓Decrement by smallStep
Page UpIncrement by largeStep
Page DownDecrement by largeStep
HomeJump to minValue (if set)
EndJump to maxValue (if set)
EnterCommit the current value (triggers formatting + clamping)
TabMove focus out of the field

Focus management

  • The input is the only focusable element by default
  • Stepper buttons have tabIndex={-1} (keyboard-accessible via ↑/↓, not Tab)
  • data-focused="" is set on the root element while the input is focused
  • onFocus/onBlur prop forwarding is supported

Validation and error messages

Use aria-errormessage (via NumberField.ErrorMessage) to announce errors:

<NumberField.Root
  locale="en-US"
  validate={(v) => v === null ? "Required" : v > 0 ? true : "Must be positive"}
>
  <NumberField.Label>Quantity</NumberField.Label>
  <NumberField.Input />
  <NumberField.ErrorMessage />
  {/* Gets role="alert" — announced on change */}
</NumberField.Root>

NumberField.ErrorMessage has role="alert" which causes screen readers to announce the message immediately when it appears. The input is automatically linked to it via aria-errormessage whenever the field is invalid.

Associating help text

<NumberField.Description> is automatically associated to the input via aria-describedby while it is mounted — just drop it in, no manual wiring required:

<NumberField.Root locale="en-US">
  <NumberField.Label>Quantity</NumberField.Label>
  <NumberField.Input />
  <NumberField.Description>
    Enter a value between 0 and 100.
  </NumberField.Description>
</NumberField.Root>

If you also pass aria-describedby on <NumberField.Input>, your value is merged with the description's id (consumer value first), not dropped. As with aria-labelledby, the wiring is applied after mount.

Accessible label patterns

<NumberField.Root locale="en-US">
  <NumberField.Label>Price</NumberField.Label>
  <NumberField.Input />
</NumberField.Root>

aria-label (no visible label)

<NumberField.Root locale="en-US">
  <NumberField.Input aria-label="Price in USD" />
</NumberField.Root>

External label

<h2 id="price-heading">Configure price</h2>
<NumberField.Root locale="en-US">
  <NumberField.Input aria-labelledby="price-heading" />
</NumberField.Root>

Automated testing

raqam is tested with jest-axe for WCAG compliance:

import { axe, toHaveNoViolations } from "jest-axe";
import { render } from "@testing-library/react";
import { NumberField } from "raqam";

expect.extend(toHaveNoViolations);

test("has no accessibility violations", async () => {
  const { container } = render(
    <NumberField.Root locale="en-US" defaultValue={42}>
      <NumberField.Label>Amount</NumberField.Label>
      <NumberField.Input />
    </NumberField.Root>
  );

  const results = await axe(container);
  expect(results).toHaveNoViolations();
});

On this page