---
title: "For developers"
description: "Semantics, names, roles, state and focus order are split between what the components will do for you and what your JSX still has to get right."
url: "https://opsinjs.pensievelabs.org/accessibility/for-developers"
source: "https://opsinjs.pensievelabs.org/accessibility/for-developers.md"
section: "Accessibility"
kind: "accessibility"
reviewed: "2026-09-20"
reviewer: "engineering"
aliases: ["aria", "accessible name", "focus order", "semantics"]
---

> Elements written as `<PascalCase … />` below are opsinjs documentation
> components. Their attributes are the content: the values they render are
> generated from `tokens/*.json` and `registry/catalogue.ts` and are
> published separately at https://opsinjs.pensievelabs.org/r/index.json and under the Reference
> section.
> Nothing is missing from this page. The data simply does not live in
> the prose.

<PageTemplate kind="accessibility" />

## What we guarantee [#what-we-guarantee]

opsinjs components are built on Base UI primitives wherever they behave like a
dialog, a menu or a listbox: twenty-three of the sixty implementations import
`@base-ui/react`, so the roles, the keyboard interaction model and the state
attributes for those patterns come from a library that already implements the
APG pattern rather than from us re-deriving it. The rest either compose one of
those twenty-three or present no APG pattern of their own. That choice is the single
largest accessibility decision in the system and it is recorded as
[ADR 0001](../project/decisions/0001-base-ui-not-radix.mdx).

On top of that, three contracts are required of every component in the roster:

* **State is exposed as data attributes, not as class names.** `data-status`,
  `data-category`, `data-open`, `data-starting-style` and `data-ending-style` are
  part of the public API and are covered by semver, so you can style and test
  against them. The shared vocabulary is in
  [Data attributes](../handbook/data-attributes.mdx).
* **Nothing is announced without being asked.** No component mounts a live region
  on your behalf. Announcement is opt-in with an explicit politeness, because a
  design system that guesses politeness produces either silence or a screen
  reader that will not stop talking. The contract is on
  [Screen readers](./screen-readers.mdx).
* **Focus is never trapped except in a modal surface,** and a modal surface
  always returns focus to the element that opened it.

<Callout>
  None of this has been verified on a rendered page. Every catalogue row is
  implemented and installable. No component has been reviewed, and the catalogue
  records no review date for any of the sixty. So these remain the requirements an implementation has to satisfy,
  not descriptions of tested behaviour. Their having been written is not evidence
  that they meet them.
</Callout>

## What you own [#what-you-own]

**The accessible name of everything you render.** This is the most common real
failure, and it is not something a component can fix. An icon-only button gets
its name from you. A `RangeBar` renders a bar; the sentence that says *what* is
in range is yours.

**DOM order.** CSS order is not reading order. `order`, `grid-area`, `row-reverse`
and absolute positioning all decouple the two, and screen readers, sequential
navigation and the browser's own find-in-page all follow the DOM. If you cannot
make the DOM order correct, the layout is wrong.

**Labels and their programmatic association.** Placeholder text is not a label.
A visually hidden label is a label. A label that only appears once the field has
focus is not.

**Input purpose.** SC 1.3.5 wants `autocomplete` on inputs that collect
information about the user, and health forms collect a lot of it. Name, date of
birth, postcode, telephone and email all have defined tokens. See
[Ask users for…](../patterns/ask-users-for/index.mdx) for the per-question
guidance.

**Error handling that survives a screen reader.** An error rendered in red beside
a field, with no programmatic association and no announcement, does not exist for
a large fraction of your readers. The pattern is on
[Error and empty messages](../content/error-and-empty-messages.mdx) for the words
and [Validation timing](../patterns/forms/validation-timing.mdx) for the
mechanics.

## How to check [#how-to-check]

<Steps>
  ### Unplug the mouse [#unplug-the-mouse]

  Do the whole task with the keyboard only. Every interactive element must be
  reachable, the focus indicator must be visible at every stop, and you must never
  land somewhere you cannot get out of. Nothing else on this list finds as many
  bugs per minute. The expected behaviour is specified on
  [Keyboard and focus](./keyboard-and-focus.mdx).

  ### Read the accessibility tree, not the DOM [#read-the-accessibility-tree-not-the-dom]

  Open the accessibility tree in your browser's developer tools and walk the
  screen. You are looking for elements with a role and no name, elements with a
  name that duplicates the visible text badly ("button button"), and headings that
  skip levels. The tree is what assistive technology sees; the DOM is not.

  ### Query the way a test would [#query-the-way-a-test-would]

  In a test, find elements by role and accessible name. The query is
  `getByRole('button', { name: 'Log a reading' })`. If you cannot express the
  element that way, a screen-reader user cannot find it either. This turns an
  accessibility property into an ordinary failing test, which is the only form of
  accessibility check that survives a deadline.

  ### Force the preference queries on [#force-the-preference-queries-on]

  Toggle `prefers-reduced-motion`, `prefers-reduced-transparency` and
  `prefers-contrast` in developer tools and re-run the task. In this codebase the
  first two are implemented in `app/globals.css` and `app/product.css`; the third
  is not, and that gap is documented on
  [Increased contrast](./increased-contrast.mdx). Emulating a preference is not the
  same as testing it, but it catches the class of bug where a transition was the
  only signal that something changed.

  ### Turn the network off mid-task [#turn-the-network-off-mid-task]

  Health UI reads stale data as if it were live more often than it reads nothing at
  all. Check that the stale state is distinguishable programmatically and not only
  by a greyer colour. See
  [Uncertainty, staleness and missing data](../health/uncertainty-and-staleness.mdx).
</Steps>

## Measured results [#measured-results]

<NoDataYet what="The aggregated conformance table" script="scripts/build-registry.mts">
  The registry holds sixty built components, but no generator reads an
  accessibility result out of them: `scripts/build-registry.mts` has no
  conformance step, `<A11yReport>` takes its counts as props, and no catalogue row
  records a review. There is code to aggregate and no measurement to
  aggregate from.
</NoDataYet>

Per-component conformance blocks, keyboard tables and the aggregated keyboard
reference are all meant to be generated from the registry. Until a person or a
tool that reads a rendered accessibility tree has actually evaluated a
component, its row here would be a number nobody produced, and the placeholder
above is the honest substitute. What does run today is described under *Known
gaps*: static source assertions, and nothing else.

## Known gaps [#known-gaps]

* **The assertions that do run are static ones.** `pnpm run check:a11y` blocks
  every pull request, but it reads your component as text: it sees that a file
  imports a lucide icon and reads `CLINICAL_STATUS_META`, not that either one
  reaches the DOM, and a status word hidden in an `sr-only` span passes it
  clean. There is still no axe integration and no jsdom test, so nothing
  anywhere asserts a property of a rendered accessibility tree.
* **The announcement contract is specified but unimplemented,** so there is no
  shared helper for polite and assertive regions yet.
* **No RTL testing has been done.** The system is authored logical-property-first
  but that has not been verified. See
  [Internationalisation and RTL](../handbook/internationalisation.mdx).

## Updates to this page [#updates-to-this-page]

<Reviewed />
