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
| Key | Action | Notes |
|---|---|---|
| Tab | Move to the next focusable element | Composite widgets are one stop, not many |
| Shift + Tab | Move to the previous focusable element | |
| Arrow keys | Move within a composite widget | Tabs, menus, radio groups, listboxes |
| Home / End | First / last item within a composite widget | |
| Enter | Activate the focused control | On a link, follow it |
| Space | Activate a button, toggle a checkbox or switch | Never scrolls when a control has focus |
| Esc | Dismiss the topmost dismissible surface | Returns focus to the trigger |
Four rules sit behind that table and are the part worth arguing about:
- 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.
- 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.
- 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.
- Focus order follows DOM order, and DOM order follows reading order.
Positive
tabindexvalues 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
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:
DialogandSheetsit 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.Buttonwarns in development when it is handed the positivetabIndexthe 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.
Text resizing and zoom
The 200% text check and the 400% reflow check are different tests that fail in different ways. It also says why a truncated number is a safety defect.
Screen readers
The combinations we intend to test, the announcement contract every status change must satisfy, and an honest table of untested cells.