Skip to content

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:

  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.
  2. Operable — every interaction is keyboard-accessible. No focus traps. Configurable response times. Motion does not trigger seizures (no flashes >3Hz).
  3. Understandable — readable text, predictable behavior, errors clearly identified, correction instructions provided.
  4. 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, not OK).
  • Icon-only buttonsaria-label is required, describing the action:
    tsx
    <button type="button" aria-label="Close dialog">
      <XIcon aria-hidden="true" />
    </button>
  • Disabled state — use the HTML disabled attribute (not just aria-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 by htmlFor/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-describedby pointing 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 autoComplete on every field (email, current-password, given-name, etc.).

2.3 Color and contrast

ContentMinimum 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 Tab in logical order (DOM order = visual order; avoid tabindex >0).
  • Enter/Space activates buttons and links.
  • Escape closes modals, drawers, popovers, dialogs.
  • Arrow keys (Arrow*) navigate within lists, menus, comboboxes (per role).
  • Home/End jump to extremes in lists and tables.
  • Focus always visible. Don't override the design system's global :focus-visible to 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 one h1.
  • <main id="main-content"> for the primary content. SkipLink points 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 use alt="" (empty string, not omitted).
  • Decorative icons: aria-hidden="true". Functional icons (icon-only button) use aria-label on the parent button and aria-hidden on 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: assertive priority (interrupts SR).
  • Confirmations and progress: polite priority (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

tsx
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

tsx
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 document doesn't exist.
tsx
<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>

tsx
<button>
  <SearchIcon aria-hidden="true" />
  <VisuallyHidden>Search</VisuallyHidden>
</button>
  • Visually hidden, kept in screen readers.
  • Tag configurable via as.

<LiveRegion>

tsx
<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> or aria-label/aria-labelledby.
  • [ ] Form errors have aria-invalid + aria-describedby pointing to the message.
  • [ ] Tab traverses the entire page in logical order.
  • [ ] Escape closes any overlay (modal, drawer, menu).
  • [ ] :focus-visible is visible on every focusable element (no global outline: 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 useAnnounce for async actions.
  • [ ] axe-core passes 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 .tsx files in apps/ looking for suspicious patterns (img without alt, button without aria-label, etc.).
  • CI — Playwright with @axe-core/playwright running in smoke E2E.

5.3 Screen readers

Platforms we test before release:

OSScreen readerVersion
macOSVoiceOverlatest
WindowsNVDA2024+
iOSVoiceOver17+
AndroidTalkBack14+

6. Common patterns

6.1 Modal / Dialog

tsx
<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

tsx
<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:

tsx
useEffect(() => {
  if (loading) announce('Loading...');
}, [loading]);

7. References


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.

MIT License — built with Inertia + React + Laravel.