opsinjs
AccessibilityContracts

Keyboard and focus

The global keyboard contract every opsinjs component inherits, and the focus rules that a health screen breaks most often.

What we guarantee

Every component in the roster inherits one keyboard contract. It is inherited rather than re-derived because the primitive layer is Base UI, which implements the ARIA Authoring Practices patterns; opsinjs adds health semantics on top and does not reimplement roving tabindex, typeahead or dismiss behaviour.

The global contract

KeyActionNotes
TabMove to the next focusable elementComposite widgets are one stop, not many
Shift + TabMove to the previous focusable element
Arrow keysMove within a composite widgetTabs, menus, radio groups, listboxes
Home / EndFirst / last item within a composite widget
EnterActivate the focused controlOn a link, follow it
SpaceActivate a button, toggle a checkbox or switchNever scrolls when a control has focus
EscDismiss the topmost dismissible surfaceReturns focus to the trigger

Four rules sit behind that table and are the part worth arguing about:

  1. A composite widget is one tab stop. A group of seven day-selector chips is one stop with arrow keys inside it, not seven stops. A daily-log screen built the other way costs a keyboard user forty presses to reach the save button.
  2. Focus is never trapped except inside a modal surface, and every modal surface returns focus to the element that opened it when it closes. If the trigger is gone by then, focus moves to the nearest sensible ancestor and that placement is announced. A trigger goes missing when an action deletes the list row it sat in.
  3. Focus is never obscured. SC 2.4.11 is new in WCAG 2.2 and it is failed constantly by exactly the pattern consumer health apps love: a sticky bottom action bar. Scroll-into-view must account for sticky chrome on all four edges.
  4. Focus order follows DOM order, and DOM order follows reading order. Positive tabindex values are banned outright. If you need to change the order, change the markup.

Focus appearance

The focus indicator is a token pair, is drawn outside the element's own border so it survives on a coloured surface, and is never removed on the grounds that it is ugly. :focus-visible governs whether it is shown, so a pointer user does not see it and a keyboard user always does. The derivation is on Interaction states.

What you own

Anything you make focusable. A div with an onClick is not keyboard reachable, does not respond to Enter or Space, and has no role. Use a button.

Skip links and landmarks. A health dashboard with twelve metric tiles above the primary action needs a way past them. Landmarks (main, nav, header) and a skip link are product-level and cannot be shipped by a component library.

Where focus goes after your state change. Deleting a row, submitting a form, closing a wizard step: each one leaves focus somewhere. If you do not decide, focus falls back to body and the reader is teleported to the top of the page with no announcement. This is the most common keyboard defect we expect to see in products built on this system.

Custom shortcuts. If you add single-character shortcuts, SC 2.1.4 requires they can be turned off, remapped, or are active only on focus. The docs site itself disables fumadocs' single-key search hotkey for this reason.

How to check

Walk the whole screen with Tab

Count the stops and compare with what you expected. Expected result: the count matches the number of interactive things, composite widgets count as one, and the order matches the visual reading order. Failure looks like thirty stops on a screen with eight controls.

Watch the focus ring, do not assume it

At every stop, confirm the indicator is visible and fully on screen. Scroll to the bottom of a long page with a sticky footer and tab into the last control. Failure looks like a ring half-hidden behind the footer.

Open and close everything

Every dialog, sheet, menu and popover: open with the keyboard, press Esc, and confirm focus is back on the trigger. Then open one, delete the thing that opened it, and confirm focus lands somewhere reasonable.

Try it without a visible pointer

Unplug or disable the mouse for one whole session of real use. Twenty minutes of this finds more than any audit checklist.

Measured results

The aggregated keyboard table has not been generated.

It would be assembled from the <KeyboardTable> on each component page, and scripts/build-reference.mts does not parse those blocks yet.

Nothing on this site types a measured number by hand, so an ungenerated table shows this rather than an example.

A keyboard table is mandatory on every component page, and those tables are meant to be aggregated here and into the generated keyboard reference. Every implemented component carries one; nothing collects them, so the reference is empty for a reason that has nothing to do with the components. The global contract above is authored, not generated, because it is a specification rather than a measurement, and aggregating the tables would not make it one.

Known gaps

  • No keyboard behaviour has been independently verified. It has been implemented: Dialog and Sheet sit on Base UI and add their own handling of Esc. That handling is a dismissal that can be refused, and it returns focus to the trigger. Button warns in development when it is handed the positive tabIndex the table above bans. Every component in the catalogue has now been audited against WCAG 2.2 AA, in a static source pass and a rendered pass, and the findings were fixed in the same change. That audit was run by the authors of the components, not by an independent party, so no independent accessibility review is recorded for any component; see ADR 0025 for the record. No automated check presses a key, and no clinical review has happened for any component. The table above is the contract every component must meet, and it is still not a report of independently tested behaviour.
  • There is no shipped skip link or landmark helper.
  • Focus-restoration behaviour after destructive actions is specified in prose only and has no tested implementation.

Updates to this page

Last read through against the system on 2026-09-02. Due for review every 6 months; expiry is reported by pnpm run check:freshness.

On this page