Alcott

Loading site
Skip to content

2026-09-18 · 7 min · By Alcott Dube

How to ship accessible components with keyboard support

I build accessible components around native controls, predictable focus and documented keyboard behaviour, then test the rendered result rather than trusting the component library.

I ship accessible components by starting with native controls, implementing the keyboard behaviour for the chosen interaction pattern, and making focus predictable when content changes. Before release, I check semantics, names and states, run automated checks, and manually test keyboard operation and basic screen reader output.

Start accessible component development with native controls

My first decision is whether the component needs custom behaviour at all. A button should usually be a `<button>`. A destination should usually be an `<a href>`. Native elements already provide keyboard activation and semantics that I would otherwise have to reproduce. Mozilla's keyboard accessibility guidance is a useful baseline: an interactive element needs to be keyboard operable, not merely reachable.

I don't turn a `<div>` into a button to avoid resetting browser styles. Adding `tabindex="0"` only makes it focusable. It doesn't supply button semantics or activation with Enter and Space. Rebuilding that contract adds implementation work, test cases and opportunities for inconsistent behaviour. Inside a form, I also set `type="button"` when the control isn't meant to submit.

I separate appearance from interaction before choosing a pattern. A row of styled links may be navigation, not tabs. A disclosure may need one button and a hidden region, not a menu. The cheapest accessibility defect is the custom interaction I decide not to build.

Choose component roles, accessible names and states

For a custom widget, I use the W3C's aria authoring practices to identify the matching interaction pattern. I read its keyboard behaviour alongside its roles and states. A role is a promise about how something behaves. Adding `role="tab"` doesn't implement arrow keys, selection or panel visibility.

I write down the component's contract before implementation: its accessible name, current state, controlled content and supported actions. For a disclosure, that usually means a native button with an accurate `aria-expanded` value. Where the pattern calls for a relationship, `aria-controls` references the controlled element's identifier. That reference needs to remain correct when several component instances appear together.

I prefer visible labels and use `aria-labelledby` when existing text names a region or dialog. An icon-only button still needs a meaningful accessible name, such as 'Close filters'. I don't add labels everywhere indiscriminately. An `aria-label` can replace a useful visible name in the accessibility tree, so I inspect the computed result rather than judging the markup alone.

Three forms sit inside a circular boundary connected to a single form outside.
The loop represents contained modal focus and its return to the opening control.

Implement Tab, arrow keys and activation correctly

I distinguish moving between components from moving inside a composite widget. Tab usually moves through the page's focus sequence. Within a tab list, arrow keys move between tabs. Making every tab a separate Tab stop ignores the expected pattern and makes a long tab list tedious to cross.

For a horizontal tab list, I give the active tab `tabindex="0"` and the others `tabindex="-1"`. Left and Right Arrow move focus between tabs, wrapping at the ends. I update those values as focus moves. In manual activation mode, Enter or Space selects the focused tab, updates `aria-selected`, and displays its panel. Native buttons already generate activation events, so I avoid handling the same action twice.

I use automatic selection only when panels appear without noticeable delay, as the authoring practices recommend. If focusing a tab starts a network request, manual activation is safer. I also limit `preventDefault()` to keys the widget actually handles. A broad key handler can accidentally stop scrolling or interfere with unrelated browser behaviour.

Manage modal dialog focus and return it on close

I prefer a native `<dialog>` opened with `showModal()` when its behaviour fits the requirement. Mozilla documents how this makes the surrounding document inert and provides modal behaviour. Merely setting the `open` attribute isn't equivalent. I still provide an accessible name, a visible close button and a deliberate initial focus target.

Initial focus depends on the task. A short form may start at its first field. A long explanation may need focus on a heading with `tabindex="-1"` so its beginning stays visible. For an irreversible action, I consider placing focus on the least destructive choice. I don't automatically focus the first element returned by a selector and assume the result makes sense.

I test that Tab and Shift+Tab cannot reach the page behind the modal, and that Escape dismisses it where expected. On close, focus should normally return to its opener. If that opener disappeared, perhaps after deleting an item, I move focus to a logical surviving control. A custom modal needs these behaviours implemented, not just `aria-modal="true"`.

Keep focus stable when components update

Focus problems often come from rendering rather than keyboard handlers. Replacing a focused node can drop focus back to the document. Unstable list keys, conditional wrappers and loading states deserve inspection. I preserve the existing control where possible instead of removing it and constructing an identical-looking replacement.

I treat focus movement as an explicit response to a task. Removing a row may justify focusing the next row's action, then the previous row's action, or an add control if the list is empty. Updating a result count usually doesn't justify moving focus into the results. A restrained live-region announcement may communicate the change without interrupting the user's position.

I never use positive `tabindex` values to repair a confusing visual layout. I fix document order instead. I also keep a visible focus indicator and check it beneath sticky headers, inside scrolling containers and against selected states. A focused element that is technically present but hidden behind an overlay is still a practical failure.

Test keyboard support without a screen reader expert

I start with a manual keyboard pass on the rendered page. I reload, leave the pointer alone and complete the main task using Tab, Shift+Tab, Enter, Space, arrow keys and Escape where the pattern calls for them. I check that focus is visible throughout and never enters hidden content.

I test transitions, not just the initial state. I open and close the dialog twice, trigger validation, remove an item, and repeat the task while content is loading. I include a case with two instances of the component to catch duplicate identifiers and handlers that affect the wrong instance. These checks need attention, not specialist equipment.

I don't describe keyboard testing as proof of screen reader accessibility. I inspect the browser's accessibility tree for names, roles and states, then make a short screen reader pass through the same task. I listen for the control's name, state changes and reading order. Unclear output becomes a specific issue to investigate, rather than a reason to skip the check.

Add accessibility checks to component release tests

I split automated coverage into semantic checks and interaction checks. An accessibility checker can flag some missing names and invalid aria relationships. It cannot reliably decide whether focus landed somewhere useful after a deletion. I therefore add browser tests that press real keys and assert the focused element, selected state and visibility of controlled content.

For a dialog, I test opening from its trigger, initial focus, forward and backward tabbing, dismissal and focus restoration. For tabs, I test arrow movement separately from selection, especially in manual activation mode. I assert observable behaviour rather than internal state alone. A passing state update means little if focus remains on a detached element.

My release gate records the browsers and assistive technology checked, unresolved failures and the manual task completed. I track defects by behaviour, such as lost focus or an incorrect announced state, rather than celebrating a single audit score. When a shared component changes its keyboard contract, I require a regression test before that change reaches its consumers.

Questions people ask

Does adding aria roles make a component accessible?

No. Roles describe semantics, but they don't add keyboard handlers, focus management or state updates. I implement and test the complete interaction pattern rather than treating a role as a repair.

What is the difference between tabindex 0 and -1?

`tabindex="0"` includes an element in the normal sequential focus order. `tabindex="-1"` allows programmatic focus but excludes it from that sequence. I use the latter for managed focus targets, not as a way to disable controls.

Can I test accessibility without a screen reader?

I can catch substantial problems with keyboard testing, accessibility-tree inspection and automated checks. Those methods don't verify actual screen reader output, so I still include a basic screen reader pass and seek specialist review for complex interactions.

Should every component trap keyboard focus?

No. I contain focus within an active modal dialog, but ordinary components must let users leave. Tabs and disclosures shouldn't prevent Tab from reaching the next appropriate control.

Where I checked my thinking

Start a project