Accessibility

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

Machine-translated, not yet reviewed. Read the original

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 patterns; where the behaviour differs, there is a reason for the difference.

The keyboard: four rules for the whole library

  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. Everywhere there is a list or a range. They are the most commonly forgotten.
  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:

ElementSpaceEnter
Button, segment, accordion headeractivatesactivates
Checkboxtogglesno — a native checkbox submits the form
Radio buttonselectsselects
Text field in a forma spacesubmits the form

The full table for each component is on its page in the catalogue: the keys, the APG pattern and the caveats.

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.

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

LevelWhat it checks
Unit tests in jsdomHandlers, roles, states, announcements
A live browserThe keyboard and everything that needs layout: jsdom has no focus movement on Tab, no activation on Enter and no ResizeObserver
The package’s gatesToken contrast, prefers-reduced-motion, nested-interactive, individual overlay invariants

A separate layer is this portal itself: its pages pass axe in both themes, a keyboard accessibility check and the WCAG 2.2 requirements for reflow and target size. The formal conformance status is on the accessibility statement page.

What the library does not give you

RTL is not supported. The components are laid out with physical directions and dir="rtl" is read nowhere: a right-to-left document gets mirrored padding, offsets and panels. 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.

Last reviewed: 2026-09-01