---
title: "Keyboard and focus"
description: "The global keyboard contract every opsinjs component inherits, and the focus rules that a health screen breaks most often."
url: "https://opsinjs.pensievelabs.org/accessibility/keyboard-and-focus"
source: "https://opsinjs.pensievelabs.org/accessibility/keyboard-and-focus.md"
section: "Accessibility"
kind: "accessibility"
reviewed: "2026-09-02"
reviewer: "engineering"
aliases: ["tab order", "focus visible", "2.1.1", "2.4.11"]
---

> Elements written as `<PascalCase … />` below are opsinjs documentation
> components. Their attributes are the content: the values they render are
> generated from `tokens/*.json` and `registry/catalogue.ts` and are
> published separately at https://opsinjs.pensievelabs.org/r/index.json and under the Reference
> section.
> Nothing is missing from this page. The data simply does not live in
> the prose.

<PageTemplate kind="accessibility" />

## What we guarantee [#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 [#the-global-contract]

| Key                               | Action                                         | Notes                                    |
| --------------------------------- | ---------------------------------------------- | ---------------------------------------- |
| <Kbd>Tab</Kbd>                    | Move to the next focusable element             | Composite widgets are one stop, not many |
| <Kbd>Shift</Kbd> + <Kbd>Tab</Kbd> | Move to the previous focusable element         |                                          |
| <Kbd>Arrow</Kbd> keys             | Move within a composite widget                 | Tabs, menus, radio groups, listboxes     |
| <Kbd>Home</Kbd> / <Kbd>End</Kbd>  | First / last item within a composite widget    |                                          |
| <Kbd>Enter</Kbd>                  | Activate the focused control                   | On a link, follow it                     |
| <Kbd>Space</Kbd>                  | Activate a button, toggle a checkbox or switch | Never scrolls when a control has focus   |
| <Kbd>Esc</Kbd>                    | Dismiss the topmost dismissible surface        | Returns 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 [#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](../foundations/interaction-states.mdx).

## What you own [#what-you-own]

**Anything you make focusable.** A `div` with an `onClick` is not keyboard
reachable, does not respond to <Kbd>Enter</Kbd> or <Kbd>Space</Kbd>, 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 [#how-to-check]

<Steps>
  ### Walk the whole screen with Tab [#walk-the-whole-screen-with-tab]

  Count the stops and compare with what you expected. &#x2A;*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 [#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 [#open-and-close-everything]

  Every dialog, sheet, menu and popover: open with the keyboard, press
  <Kbd>Esc</Kbd>, 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 [#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.
</Steps>

## Measured results [#measured-results]

<NoDataYet script="scripts/build-reference.mts" what="The aggregated keyboard table">
  It would be assembled from the `<KeyboardTable>` on each component page, and
  `scripts/build-reference.mts` does not parse those blocks yet.
</NoDataYet>

A keyboard table is mandatory on every component page, and those tables are
meant to be aggregated here and into
[the generated keyboard reference](../reference/generated/keyboard.mdx). 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 [#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 <Kbd>Esc</Kbd>. 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](../project/decisions/0025-the-audit-is-author-run.mdx)
  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 [#updates-to-this-page]

<Reviewed />
