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
| Attribute | Value | Notes |
|---|---|---|
role | "spinbutton" | Always set |
aria-valuenow | numberValue | Current numeric value |
aria-valuemin | minValue | Only set when minValue is provided |
aria-valuemax | maxValue | Only set when maxValue is provided |
aria-valuetext | formatted string | Localized display value for screen readers |
aria-invalid | boolean true when invalid | Set by validate callback or allowOutOfRange |
aria-disabled | true when disabled | |
aria-readonly | true when readOnly | |
aria-required | true when required | Pass required prop |
aria-labelledby | — | Auto-wired from <NumberField.Label> |
aria-describedby | description element id | Auto-wired from a mounted <NumberField.Description>, merged with any aria-describedby you pass on <NumberField.Input> — see Associating help text |
aria-errormessage | error element id | Auto-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 theincrementLabel/decrementLabelprops on<NumberField.Root>(defaults"Increase"/"Decrease"). You can still override per-button by passing your ownaria-labelon<NumberField.Increment>/<NumberField.Decrement>— it takes precedence over the default.tabIndex={-1}: Buttons are intentionally outside the Tab orderdisabled: When atminValue/maxValueor when the field is disabled
The scrub area is labelled via <NumberField.ScrubArea label="…"> (default
"Scrub to change value").
Keyboard navigation
| Key | Action |
|---|---|
| ↑ | 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 Up | Increment by largeStep |
| Page Down | Decrement by largeStep |
| Home | Jump to minValue (if set) |
| End | Jump to maxValue (if set) |
| Enter | Commit the current value (triggers formatting + clamping) |
| Tab | Move 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 focusedonFocus/onBlurprop 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
Visible label (recommended)
<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();
});