---
title: Accessibility
description: The accessibility features the primitives provide, and what your application still has to supply.
---

The primitives handle ARIA attributes, roles, keyboard navigation, and focus
management. Because the package renders no styles, anything visual — contrast,
focus indication — is your application's responsibility.

## Keyboard navigation

The primitives follow the [WAI-ARIA Authoring Practices](https://www.w3.org/WAI/ARIA/apg/).
The composer's editor interprets keys by mode: an open command list, or an active
ask-user flow, takes priority over normal typing. A primitive that claims keys of
its own documents them in a key table on its page; one that relies on native focus
order does not.

Anything draggable is also operable from the keyboard. The shell's grip resizes
the sidebar with the arrow keys once focused, so no layout a visitor can reach
with a pointer is closed to them without one.

A strip or a tree is one tab stop, not one per item. Focus follows the arrow
keys without selecting, which is manual activation; a primitive that should
select as focus moves says so through a prop rather than by default. The nav is a roving-tabindex widget:
exactly one row is tabbable and the arrow keys move focus between rows. Before
that state is seeded — a server render, or the frame before hydration — the
active row carries the tab stop, which keeps a row rendered as a real anchor
reachable without JavaScript. A field inside the tree keeps its own arrow keys,
or its caret could never move.

A scroll surface has to be reachable before it can be scrolled. The thread's
viewport is a focusable `role="region"` named "Messages" by default, so a
keyboard user can Tab to it and use the usual keys. Only the upward keys —
`ArrowUp`, `PageUp` and `Home` — are intercepted, to release auto-follow;
scrolling down never does, because releasing it at the bottom would leave the
view unfollowed while pinned there.

Announce additions, not mutations. The thread's content column is a `role="log"`
with `aria-relevant="additions"`, so a turn announces when its row is added and
tokens streaming into an existing row deliberately do not re-announce.
Token-by-token narration is noise. If an end-of-response announcement is wanted,
add your own `role="status"` region that flips on completion.

Motion preferences are honoured for programmatic scrolls. Anything the thread
requests as smooth, including auto-follow, downgrades to instant when the
operating system asks for reduced motion.

Command lists and ask-user options move a highlight rather than DOM focus, so the
caret stays in the editor while you navigate. Arrowing past the end of a list
keeps focus in place; arrowing up past the first ask-user option returns to the
editor.

## Focus management

Focus is managed automatically after interaction — the composer restores the
caret after selecting a command item, and ask-user recovers arrow navigation if a
click elsewhere drops focus to the body.

In command lists and ask-user options the highlight *is* the focus indication —
focus follows it rather than moving separately, so styling the highlight faintly
removes the focus indicator rather than softening a hover effect.

## Roles and relationships

The composer's editor is wired to its command list as a combobox:
`aria-autocomplete="list"` and `aria-haspopup="listbox"`, plus `aria-controls`
and `aria-activedescendant` while a popup is open. The popup is a `listbox`
named "Suggestions" whose rows carry stable ids and `aria-selected`; selection
stays in the editor, so rows are never tab stops. Grouped lists wrap in
`role="group"` named by their label, async resolution sets `aria-busy`, and the
empty state is a `role="status"` region.

That pattern deviates from the APG combobox in one place, deliberately.
`aria-expanded` is omitted because ARIA 1.2 forbids it on `textbox`, and moving
to `role="combobox"` would forbid `aria-multiline`, which matters more for a
chat field that wraps. The popup announces through `aria-haspopup` and live
`aria-activedescendant` narration instead, and Tab selects the highlighted
option rather than moving focus.

Ask-user renders a labelled `radiogroup`, or `role="group"` with checkboxes for
multi-select. Each option is the real control, holding the group's single tab
stop through roving `tabindex`, so never nest an interactive control inside one:
the option *is* the control, and the composer's question mode treats native
inputs as foreign editables. Disabled options are announced but skipped by
highlight and selection.

A placeholder overlay is decoration. The composer's is `aria-hidden`, and the
string reaches assistive technology through `aria-placeholder` instead — so when
rich `children` replace the string visually, keep the `placeholder` string
alongside or the hint stops announcing.

Clicking chrome should not create a tab stop. The composer's container is a
mouse-only focus passthrough: clicking it focuses the editor, clicks on nested
buttons and links pass through, and it carries no role, so keyboard users tab
straight to the editor.

`Steps` marks the active item with `aria-current="step"`. A disclosure built for
streaming says so while it runs: `Reasoning.Root` carries `aria-busy` until
`isStreaming` flips false, so assistive technology knows the text underneath is
still arriving rather than finished.

Disclosure controls announce both their state and what they govern: the shell's
trigger is a `<button>` with `aria-expanded` and an `aria-controls` pointing at
the sidebar it opens. Its grip is a focusable `role="separator"` carrying
`aria-orientation` and the `aria-valuenow` / `aria-valuemin` / `aria-valuemax`
trio, and it always carries a value, falling back to the minimum before the first
measurement lands — a focusable separator without one is an invalid widget.

Inline tokens announce as part of the sentence they sit in. A chip's label
reads as ordinary text, its icon is `aria-hidden` so decoration never announces,
and a chip committed in the composer's editor carries an `aria-label` naming it
as a mention so it reads as one token rather than a stray word.

Where the package hands you a surface to build, the obligations come with it.
`Chip` never renders its own hover preview — `renderWithPreview` is the seam —
so whatever you lift it into has to honour the hover-card contract: open on
keyboard focus as well as hover, and dismiss on Escape.

A part that exists only for the pointer is hidden from everything else. The
shell's hotspot, the strip that floats a collapsed sidebar back out on hover, is
`aria-hidden`, because the trigger already covers the same state change for
keyboard and screen-reader users.

The nav's trigger carries the same `aria-expanded` and `aria-controls` pair,
pointing at the list it governs. Disabled rows get `aria-disabled` rather than
leaving the focus ring, so they stay discoverable.

Status that only exists in colour reaches nobody. `Steps.Icon` is `aria-hidden`
and a tint announces nothing, so a row whose state matters needs `Steps.Status`
mounted inside it — a visually hidden span speaking the resolved status, which
takes `children` when the wording should be localised. The item whose status is
`"active"` also carries `aria-current="step"`. `Nav.Action` sits outside the
roving order and stops every event it handles, or activating it would also
activate the row wrapped around it.

Where a row is not a native control, activation is wired by hand. That is the
price of a row that can hold an action rather than being a `<button>`, and a row
that already activates itself — a real button, or an anchor swapped in through
`render` — is left alone rather than activated twice.

Where the standard role cannot describe the widget, the primitives say so and
choose a different one rather than fail silently. A closable, open-ended tab
strip is a `toolbar` of disclosures, not a `tablist`: a tablist may own only
tabs, leaving nowhere for a close button, and it requires one selected tab, so
"nothing open" is not a state it can express. Each trigger then carries
`aria-expanded` and an `aria-controls` that only claims a viewport really in the
document. Static, always-one-selected tabs are a different widget, and `tablist`
is right for those.

A floating surface that you work behind is non-modal throughout: no backdrop, no
scroll lock, no focus trap, and no dismissal on outside press. Focus is never
taken from you, but it is also never returned, so give such a surface its own
close control — `Escape` then duplicates it rather than being the only way out.

## Accessible labels

A visual role is not an announced one. A message's `role` is an opaque string
the package only surfaces as `data-role`, so alignment and colour carry it to
sighted readers and nothing carries it to anyone else. Give each message a
visually hidden `{role} said` prefix, or an `aria-label` on the root, or a
transcript read top to bottom will not attribute its turns.

Status markers around a message are your markup, so their roles are too. An
inline failure marker wants `role="alert"` so it announces immediately; quieter
states such as "stopped" or "generating…" want `role="status"`. Anything you add
beside a message — copy, regenerate, quote — should be a real button with an
accessible name.

Interactive parts carry default accessible names; containers do not, since they
are not controls. `Composer.Submit` announces as "Stop generating" while
generating rather than "Send message", so a control that morphs still names what
it does. Submitting is exposed as `data-submitting` and nothing else: the
package emits no live-region copy, so add your own `role="status"` region if
in-flight announcements beyond the button state are wanted. `Attachments.Remove` takes a `filename` so
each remove button announces its own item.

Where a name would otherwise repeat, the package takes the distinguishing part
as a prop. `Attachments.Remove` defaults to "Remove attachment", which is useless
when five of them sit in a row, so pass `filename` and each announces its own
item. `Attachments.Trigger` defaults to "Add attachment".

A validation slot is a live region. `Attachments.Error` renders `role="alert"`,
so whatever appears inside it announces immediately — the copy stays yours,
because validation emits a code rather than a message.

All defaults are overridable via `aria-label` or `aria-labelledby`. Anything
announced as content rather than as a name — status text, validation messages —
is text you render, so it is localised wherever your copy lives.

## Testing

The package runs an axe-based suite over the composer combobox wiring, the
ask-user flows, the display primitives' status attributes, and accessible names
across controls. Automated checks can't evaluate contrast or focus visibility in
your styling, so those need your own pass.
