Accessibility (a11y)
Arqel's canonical accessibility guide. Baseline: WCAG 2.1 level AA. This document is the single reference for authors of components, plugins, themes, and applications built with Arqel. Pull requests that regress a11y will be rejected.
Accessibility is not "final polish" in Arqel — it is a functional requirement. An inaccessible admin panel excludes real users (operators with low vision, reduced mobility, cognitive disabilities, noisy environments where audio is unfeasible, etc.). Since Arqel is the operations layer of many internal systems in Brazil and around the world, we directly hurt real people's work when a11y fails.
1. Principles
We follow the four POUR principles of WCAG 2.1:
- Perceivable — information and UI must be presented so any user can perceive them. Images have alt text, videos have captions, contrast is sufficient, content does not depend solely on color.
- Operable — every interaction is keyboard-accessible. No focus traps. Configurable response times. Motion does not trigger seizures (no flashes >3Hz).
- Understandable — readable text, predictable behavior, errors clearly identified, correction instructions provided.
- Robust — valid markup, correct semantics, compatibility with assistive technologies (screen readers, switches, eye-trackers, magnifiers).
Target level
- AA is our baseline. Features that don't reach AA do not make it into a stable release.
- AAA is desirable where possible (especially 7:1 contrast in "contrast-extra" mode).
2. Required conventions
2.1 Buttons
- Buttons with text — use descriptive text (
Save, notOK). - Icon-only buttons —
aria-labelis required, describing the action:tsx<button type="button" aria-label="Close dialog"> <XIcon aria-hidden="true" /> </button> - Disabled state — use the HTML
disabledattribute (not justaria-disabled); avoid visually hiding unavailable buttons. When a button becomes disabled after an action, announce the reason via a live region.
2.2 Forms
- Every
<input>must have a<label>associated byhtmlFor/id:tsx<label htmlFor="email">Email</label> <input id="email" type="email" name="email" required /> - For compact-layout forms, use
<VisuallyHidden as="label">instead of removing the label. - Error messages must have
aria-describedbypointing to the input:tsx<input id="email" aria-invalid={hasError} aria-describedby="email-error" /> {hasError && <p id="email-error" role="alert">Invalid email</p>} - Required fields marked with HTML
required+ visual indicator (don't rely on color only). - Appropriate
autoCompleteon every field (email, current-password, given-name, etc.).
2.3 Color and contrast
| Content | Minimum contrast |
|---|---|
| Normal text (<18.66px regular or <24px bold) | 4.5:1 |
| Large text (≥18.66px regular or ≥24px bold) | 3:1 |
| UI components and graphics (borders, functional icons) | 3:1 |
- Never convey information only through color (e.g. "red fields are required" without another visual and textual indicator).
- Hover/focus/active states must have a detectable change beyond color (underline, border, icon, etc.).
2.4 Keyboard
- Everything focusable via
Tabin logical order (DOM order = visual order; avoidtabindex>0). Enter/Spaceactivates buttons and links.Escapecloses modals, drawers, popovers, dialogs.- Arrow keys (
Arrow*) navigate within lists, menus, comboboxes (per role). Home/Endjump to extremes in lists and tables.- Focus always visible. Don't override the design system's global
:focus-visibleto remove it. If the design considers it "ugly", design your own focus ring — never hide it.
2.5 Semantic structure
- Use native elements:
<button>,<a href>,<form>,<nav>,<main>,<aside>,<header>,<footer>,<section>,<article>. They bring semantics, focus, and keyboard support for free. Create ARIA roles only when no native element exists. - Heading hierarchy (
h1...h6) without skipping levels. Each page has oneh1. <main id="main-content">for the primary content.SkipLinkpoints to that id.- ARIA landmarks are implicit via semantic tags — don't duplicate with
role="navigation"on<nav>.
2.6 Images and icons
<img alt="...">always. Decorative images usealt=""(empty string, not omitted).- Decorative icons:
aria-hidden="true". Functional icons (icon-only button) usearia-labelon the parent button andaria-hiddenon the icon. - SVGs with
<title>when they convey meaning on their own.
2.7 Live regions and announcements
- Async action results (save, delete, import) must be announced via the
useAnnounce()helper from@arqel-dev/a11y. - Critical errors:
assertivepriority (interrupts SR). - Confirmations and progress:
politepriority (waits for SR to be free).
3. @arqel-dev/a11y helpers
The @arqel-dev/a11y package exposes reusable primitives. Always prefer them to re-writing focus traps or live regions on your own.
useFocusTrap
import { useFocusTrap } from '@arqel-dev/a11y';
const ref = useFocusTrap<HTMLDivElement>(open, { onEscape: onClose });
return <div ref={ref} role="dialog" aria-modal="true">...</div>;- Focuses the first element on activation.
- Cycles Tab/Shift+Tab inside the container.
- Optional
onEscape— closes the modal/drawer. - Restores focus to the previously-focused element (configurable).
useAnnounce
const { announce } = useAnnounce();
announce('Record saved successfully');
announce('Failed to save — try again', 'assertive');- Reuses global live regions (one per priority).
- SSR-safe: silently ignores when
documentdoesn't exist.
<SkipLink targetId>
<SkipLink targetId="main-content" />
<main id="main-content" tabIndex={-1}>...</main>- Renders a link visible only on focus.
- Skips repetitive navigation — first focusable element on the page.
<VisuallyHidden>
<button>
<SearchIcon aria-hidden="true" />
<VisuallyHidden>Search</VisuallyHidden>
</button>- Visually hidden, kept in screen readers.
- Tag configurable via
as.
<LiveRegion>
<LiveRegion message={status} priority="polite" />- For when you need to control the region via prop instead of the hook (e.g. content reactive to a store).
4. PR checklist
Use this checklist on any PR that touches UI:
- [ ] Semantic markup (no
<div onClick>for buttons). - [ ] Icon-only buttons have
aria-label. - [ ] Inputs have
<label htmlFor>oraria-label/aria-labelledby. - [ ] Form errors have
aria-invalid+aria-describedbypointing to the message. - [ ]
Tabtraverses the entire page in logical order. - [ ]
Escapecloses any overlay (modal, drawer, menu). - [ ]
:focus-visibleis visible on every focusable element (no globaloutline: none). - [ ] Contrast verified (DevTools color picker or plugin).
- [ ] Images have descriptive
alt(or empty if decorative). - [ ] Headings in order (no jumping
h2 → h4). - [ ] Color is not the only carrier of information.
- [ ] Announcements via
useAnnouncefor async actions. - [ ]
axe-corepasses zero violations on the component.
5. Tools
5.1 Manual
- axe DevTools (Chrome/Firefox extension) — full page analysis.
- Lighthouse (Chrome DevTools) — basic score + suggestions.
- VoiceOver (macOS,
Cmd+F5) — test real narration and navigation. - NVDA (Windows, free) — test real narration and navigation.
- Tab through — unplug the mouse and navigate with the keyboard for 5 minutes.
5.2 Automated
- vitest-axe in component tests:ts
import { axe } from 'vitest-axe'; expect(await axe(container)).toHaveNoViolations(); pnpm --filter @arqel-dev/a11y audit:scan— heuristic sweep of.tsxfiles inapps/looking for suspicious patterns (img without alt, button without aria-label, etc.).- CI — Playwright with
@axe-core/playwrightrunning in smoke E2E.
5.3 Screen readers
Platforms we test before release:
| OS | Screen reader | Version |
|---|---|---|
| macOS | VoiceOver | latest |
| Windows | NVDA | 2024+ |
| iOS | VoiceOver | 17+ |
| Android | TalkBack | 14+ |
6. Common patterns
6.1 Modal / Dialog
<div
ref={trapRef}
role="dialog"
aria-modal="true"
aria-labelledby="dialog-title"
aria-describedby="dialog-desc"
>
<h2 id="dialog-title">Confirm</h2>
<p id="dialog-desc">Are you sure?</p>
<button onClick={onCancel}>Cancel</button>
<button onClick={onConfirm}>Confirm</button>
</div>6.2 Sortable table
<th aria-sort={sortBy === 'name' ? sortDir : 'none'}>
<button onClick={() => sort('name')}>Name</button>
</th>6.3 Combobox / Autocomplete
Follow WAI-ARIA Authoring Practices §3.5. role="combobox", aria-expanded, aria-controls, aria-activedescendant. The @arqel-dev/ui package already encapsulates this — prefer the ready-made primitive over reimplementing.
6.4 Loading state
Don't hide focus while loading. Announce via useAnnounce:
useEffect(() => {
if (loading) announce('Loading...');
}, [loading]);7. References
- WCAG 2.1: https://www.w3.org/TR/WCAG21/
- WAI-ARIA Authoring Practices: https://www.w3.org/WAI/ARIA/apg/
- A11y Project Checklist: https://www.a11yproject.com/checklist/
- MDN Accessibility: https://developer.mozilla.org/en-US/docs/Web/Accessibility
- Inclusive Components (Heydon Pickering): https://inclusive-components.design/
When in doubt: prefer too much a11y. The cost of adding aria-label is zero, the cost of not having it is one excluded user.