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
- 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).
- 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.
- Home and End go to the edges. Everywhere there is a list or a range. They are the most commonly forgotten.
- Tab inside an open panel closes it.
GrDropdown,GrSelectandGrAutocompletehand 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 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
inertmutes 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
restoreFocusturned 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:
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
| 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: 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.