# Accessibility

> The keyboard contract, the overlay stack, the live region and the contrast rules — what the library guarantees and what holds it.

- Page: https://granularity.tech/docs/accessibility
- Last reviewed: 2026-10-15

Accessibility here is not a property of individual components but a contract: the
same rules across all of them, deviations spelled out, and tests rather than good
intentions holding them. The components implement the
[WAI-ARIA APG](https://www.w3.org/WAI/ARIA/apg/patterns/) patterns; where the
behaviour differs, there is a reason for the difference.

## The keyboard: four rules for the whole library

:::steps
1. **Tab moves between widgets, arrows move inside one.** A composite widget — a radio group, a tree, tabs, segments — takes **one** stop in the tab order; you get inside with the arrows (roving tabindex).
2. **Esc closes the topmost.** Every dismissible overlay takes part in a single stack: Esc addresses the last layer opened and does not fall through to the ones below. A list opened inside a modal closes itself on Esc, and the next Esc closes the modal.
3. **Home and End go to the edges.** In lists and ranges: menus, listboxes, the tree, tabs, the radio group, the slider, the rating, the number field. They are the most commonly forgotten — and the library has exceptions of its own: `GrImageViewer` pages through images with the arrows only, and a grabbed row in `GrSortableList` moves with the arrows only.
4. **Tab inside an open panel closes it.** `GrDropdown`, `GrSelect` and `GrAutocomplete` hand focus onward through the document — the user is not trapped in a panel they cannot see.
:::

`Enter` and `Space` are **not** interchangeable, and that is the rule of native
controls rather than a matter of taste:

| Element | Space | Enter |
|---|---|---|
| Button, segment, accordion header | activates | activates |
| Checkbox | toggles | no — a native checkbox submits the form |
| Radio button | selects | selects |
| Text field in a form | a space | submits the form |

The contract row — the keys and the APG pattern — is shown on the component's
page in the [catalogue](/components) for the components that have one in the
library's `docs/keyboard.md` (not for all of them yet); the full table with its
caveats is linked from there.

## Focus and overlays

The modal layer is assembled from six primitives — the stack, the focus trap,
`inert`, the scroll lock, the portal, presence in the DOM — and assembled **in
one place** rather than in each component. The reason is practical: skipping any
one of the invariants produces not a breakage but a quiet accessibility defect.

- **The trap holds focus only while its layer is the topmost.** Otherwise the
  window below steals focus from the one opened above it, and `inert` mutes the
  panel's own content.
- **Focus restoration belongs to the stack, not to the trap.** Two restoration
  systems would fight; that is why the trap has `restoreFocus` turned off.
- **The trap lives without sentinel nodes.** The approach with two focus-guard
  buttons at the edges puts interactive elements inside a container with an ARIA
  role, and axe catches that as `nested-interactive`.
- **While a layer plays out its exit, it is not addressable by the pointer.**
  Otherwise a click in those hundred and fifty milliseconds goes to the vanishing
  layer and disappears without a trace.

The scroll lock is a shared reference-counted lock: several open overlays release
it independently of the order they close in, and the width of the vanished
scrollbar is compensated so the content does not jump.

## Announcements for a screen reader

An event that has to be told but has nowhere to be shown is announced by the
shared live region:

```ts
import { useAnnouncer } from '@feugene/granularity'

const { announce } = useAnnouncer()

announce('Link copied')
announce('Could not save', { politeness: 'assertive' })
```

The line runs along the nature of the text. The **state** of a widget —
"loading", "nothing found", "12 characters left" — lives in a region of its own
inside the widget, next to the content it belongs to. An **event** — "tag
removed", "image 2 of 5" — goes to the announcer: it has no place of its own in
the markup.

:::callout{type="warning"}
`assertive` interrupts the current speech and is appropriate only where missing
it is not an option: an operation refused, a connection lost. A stream of
assertive announcements turns the page into an unusable one — the user never gets
to hear any of them out.
:::

## Contrast

There is one rule and it is strict: **a saturated tone is never used as a text
colour**. Every tone has a theme where it fails AA on an ordinary surface; the
paired `-text` roles hold AA everywhere. The details and the threshold table are
on the [theming](/docs/theming) page.

None of this is verified by eye: one gate catches
`text-[var(--gr-<tone>)]` in the sources, the second recomputes contrast from the
tokens of both themes — so that the list of forbidden combinations does not go
stale the first time a theme is repainted.

## Reduced motion

The system setting is respected wholesale rather than component by component: a
single block in the base layer kills the durations and delays of every transition
and animation. It covers the utilities, the overlay wrappers, the per-component
`@keyframes` and code that does not exist yet — so a new component gets the
behaviour for free.

Where the setting changes not the speed but the scenario itself — an autoplaying
carousel should not be sped up but not started — the component reads `matchMedia`
on its own.

## What verifies this

| Level | What it checks |
|---|---|
| Unit tests in jsdom | Handlers, roles, states, announcements |
| A live browser | The keyboard and everything that needs layout: jsdom has no focus movement on `Tab`, no activation on `Enter` and no `ResizeObserver` |
| The package's gates | Token contrast, `prefers-reduced-motion`, `nested-interactive`, individual overlay invariants |

A separate layer is this portal itself: axe runs over every one of its page
templates in both themes, on top of a keyboard accessibility check and the
WCAG 2.2 requirements for reflow and target size. The formal conformance status
is on the
[accessibility statement](/legal/accessibility-statement) page.

## What the library does not give you

**RTL is not supported.** The components are laid out with physical
directions, and the library as a whole does not take `dir="rtl"` into account: a
right-to-left document gets mirrored padding, offsets and panels. The exceptions
are isolated — the scroll masks of `GrTabs` and `GrCarousel` and the swipe
direction in `GrCarousel` do read `dir` — and they do not amount to support.
This is a deliberate decision for the `1.x` line — moving to logical properties
changes how every component looks and belongs to a major release rather than to
a patch.
