Skip to content
← Writing

Accessibility compounds in layers

Interface vocabulary compounds into accessible behavior.

· 6 min read

You finish filling out a form. You tab through the last few fields, land on the submit button, and press . Nothing happens. You press it again. You reach for the mouse, click the same button, and the form submits.

The control looked right. The contract underneath it was broken.

Accessibility bugs often appear when the interface changes shape: a button becomes icon-only, a form field moves into a dialog, a menu item turns into a link, a list starts loading asynchronously. The JSX still renders. The interaction no longer carries enough meaning for the browser, assistive technology, tests, or future teammates to preserve the behavior.

Accessibility starts with the vocabulary of the interface.

What is this thing called? What kind of thing is it? What does it control? What owns it? What happens when it changes? If the product has clear answers, code can preserve them. If the product does not, every layer has to guess.

A product word becomes a data structure. The data structure gives components a hierarchy. The hierarchy lets primitives choose semantics. The semantics let the platform expose behavior to more people, tools, and contexts.

Build a dictionary

The first layer is the dictionary: the names the product uses for commands, regions, fields, states, and relationships.

UI gets fragile when call sites answer platform questions directly:

<button aria-label="Close" onClick={close}>
  <XIcon />
</button>

This is not always wrong, but it is a bad default. It makes accessibility look like local decoration instead of product language.

The call site knows the product word: close, delete, publish, invite, archive. The primitive knows how to turn that word into platform semantics.

Feature code should describe the primitive's intent:

<Dialog title="Delete project" description="This action cannot be undone">
  <Button variant="destructive">Delete</Button>
</Dialog>

Product code speaks product vocabulary. Components translate that vocabulary into semantics and behavior.

Structure carries meaning

The next layer is structure. The most useful accessibility work often looks like data modeling.

An icon button is a named command:

<IconButton label="Close">
  <XIcon />
</IconButton>

A field is a small hierarchy: label, hint, control, error.

<Field label="Email" hint="Use your work email." error={form.errors.email}>
  <Input type="email" value={email} onChange={setEmail} />
</Field>

A menu item is an action in a dictionary. Some actions navigate. Some run commands. Some are disabled because permissions say so. That is the same reason menu actions work better as data: the action has meaning before any menu renders it.

const menuItems = [
  { label: 'Open in new tab', action: projectUrl },
  { label: 'Delete…', action: confirmDelete, disabled: !canDelete },
];

The renderer turns URL actions into links, callbacks into buttons, and labels into accessible names. A command palette, analytics event, permission check, or test can read the same data instead of reverse-engineering meaning from markup.

Hierarchy is part of the API

The next layer is hierarchy. A page has landmarks. A dialog has a title. A field has a label and an error. A tab controls a panel. A menu owns items. These relationships are not decoration; they are how the interface becomes navigable.

Make the hierarchy hard to lose:

<SettingsSection
  title="Notifications"
  description="Choose when to hear from us."
>
  <Field label="Email updates">
    <Switch checked={enabled} onCheckedChange={setEnabled} />
  </Field>
</SettingsSection>

The title names the region. The field labels the control. The switch exposes state. The component tree and the accessibility tree do not have to match perfectly, but they should agree about what the user is interacting with.

When hierarchy is only visual, it breaks easily. A heading level is skipped. An error appears in the right place on screen but is disconnected from the input it explains. The interface still looks organized, but its structure has drifted.

Encode the defaults once

The next layer is the primitive.

Dialogs, menus, popovers, tabs, comboboxes, and date pickers all have hidden behavior. The drawing is the smallest part. The real component is the focus model, keyboard model, escape hatch, and recovery path.

Those defaults belong in the component layer. A destructive action gets confirmation or a recovery path. A field carries a visible label, optional help text, and a connected error. A loading button keeps its size and prevents duplicate submits. A menu distinguishes navigation from commands. A modal blocks background interaction and gives the user an obvious way out.

Strong component libraries already ship much of that behavior. Product primitives compose it into the vocabulary of the app: a delete-project dialog, a billing-confirmation dialog, a reconnect-integration dialog. Same underlying primitive, different product contract.

Use the platform to tune behavior

The final layer is the platform. Once a primitive owns the contract, JavaScript tunes native behavior.

Focus management. document.activeElement captures the trigger before a dialog opens. element.focus() returns to it when the dialog closes. When a destructive action removes the focused row, focus moves to the next available one, and an aria-live region announces the change. The primitive owns that sequence; call sites pass none of it.

Scroll containment. scrollTop, clientHeight, and scrollHeight tell you whether a region can still scroll in the gesture's direction. When it can, it owns the wheel event. When it reaches a boundary, the remaining delta belongs to the parent.

Names and relationships. A field label should be connected to the input. An error should be connected to the field it explains. A disclosure should expose whether it is open. A tab should point at the panel it controls.

Knowing platform APIs keeps the primitive honest. You can use ARIA where it adds missing semantics, avoid it where native HTML already has them, and test the behavior users actually receive.

Try every interface

Accessibility becomes infrastructure when meaning compounds through the layers.

Try components keyboard-only. Try 200% zoom. Open a dialog from a row, close it, and check where focus lands. See what happens when an action fails or a list empties.

The best accessibility code is not something every call site remembers. It is a vocabulary the product already speaks, a hierarchy components preserve, and platform behavior primitives translate everywhere.