---
title: 'Keyboard Access'
source: 'https://academia.sh/en/courses/frontend-quality/keyboard-access'
course: 'Frontend Quality'
language: en
updated: '2026-08-17T18:11:05+00:00'
license: 'CC BY-SA 4.0'
---

# Keyboard Access

The requirements for keyboard operability, comparing tab order with visual order, the boundary where focus containment turns into a trap, and the condition on single-character shortcuts.

The previous lesson's rule audit left one row open: an element with the button role
declared but not taking focus. The declaration was correct; the behavior was absent.
That distinction is the subject of two different criteria, and the second is this
lesson's starting point.

Keyboard access is not only the problem of a minority who use a keyboard. Screen readers
navigate a page through the keyboard interface; tools driven by voice commands and switch
access use the same interface. A component that does not work with the keyboard works in
none of these tools. A practical conclusion follows: keyboard testing is the cheapest,
broadest test that can be run before trying any assistive technology.

## What the Criterion Requires

Criterion 2.1.1 requires that every function of the content be operable through a
keyboard interface. Two details of the wording matter. First, it says "keyboard
interface": not a physical keyboard, but every tool that uses that interface. Second, the
criterion also requires that individual keystrokes not depend on **specific timing**; a
key sequence that must be pressed at a particular speed does not meet the criterion.

The one exception is functions where the path of the input itself carries information —
freehand drawing, for instance. Panning a map by dragging does not fall under this
exception, because the same function can be provided with pan buttons too.

Criterion 2.1.2 is the other end of the chain: if a component can be entered by keyboard,
it must be possible to exit it through the same interface. The two criteria together form
a whole; the first secures entry, the second secures exit.

## Tab Order Must Match Visual Order

Criterion 2.4.3 requires focusable elements to be navigated in an order that preserves
meaning and operability. Tab order comes from document order; visual order comes from the
presentation layer. When the two diverge, a keyboard user jumps back and forth across the
screen.

On the measurement list page this divergence happens for two reasons: the filter panel
was added to the markup later, after the table, and the presentation layer placed it
above the table; and a positive `tabindex` value was written on the export button with
the intent of making it "come first."

```js
// tab-order.mjs — comparing tab order with visual order (2.4.3)

// visual: [row, column] — where the presentation layer places it
const PAGE = [
  { name: "a.skip-link", doc: 1, tabindex: null, visual: [0, 1] },
  { name: "button#sort-date", doc: 2, tabindex: null, visual: [3, 1] },
  { name: "button#sort-temperature", doc: 3, tabindex: null, visual: [3, 2] },
  { name: "button.delete[T-01]", doc: 4, tabindex: null, visual: [4, 1] },
  { name: "input#filter-code", doc: 5, tabindex: null, visual: [1, 1] },
  { name: "input#filter-alert", doc: 6, tabindex: null, visual: [1, 2] },
  { name: "button#filter", doc: 7, tabindex: null, visual: [1, 3] },
  { name: "button#export", doc: 8, tabindex: 1, visual: [2, 1] },
];

// Rule established in M14/K04: ascending tabindex first, then document order
function tabOrder(items) {
  const positive = items.filter((o) => (o.tabindex ?? 0) > 0)
    .sort((a, b) => a.tabindex - b.tabindex || a.doc - b.doc);
  const zero = items.filter((o) => (o.tabindex ?? 0) === 0 || o.tabindex === null)
    .sort((a, b) => a.doc - b.doc);
  return [...positive, ...zero];
}

const visualOrder = (items) =>
  [...items].sort((a, b) => a.visual[0] - b.visual[0] || a.visual[1] - b.visual[1]);

function invertedPairs(items) {
  const tab = tabOrder(items).map((o) => o.name);
  const visual = visualOrder(items).map((o) => o.name);
  const pairs = [];
  for (let i = 0; i < visual.length; i++) {
    for (let j = i + 1; j < visual.length; j++) {
      if (tab.indexOf(visual[i]) > tab.indexOf(visual[j])) pairs.push([visual[i], visual[j]]);
    }
  }
  return pairs;
}

function report(title, items) {
  console.log(title);
  const tab = tabOrder(items);
  const visual = visualOrder(items);
  console.log("  no  tab order                visual order");
  for (let i = 0; i < tab.length; i++) {
    console.log(`  ${String(i + 1).padStart(2)}  ${tab[i].name.padEnd(24)}  ${visual[i].name}`);
  }
  const pairs = invertedPairs(items);
  console.log(`  inverted pair count: ${pairs.length}`);
  for (const [a, b] of pairs.slice(0, 4)) {
    console.log(`    "${a}" comes first visually, after "${b}" in tab order`);
  }
}

report("filter panel comes after the table in source, tabindex=1 present:", PAGE);

// Fix: filter panel is moved earlier in the source, positive tabindex is removed
const FIXED = PAGE.map((o) => {
  const updated = { ...o, tabindex: null };
  if (o.name.startsWith("input#filter") || o.name === "button#filter") updated.doc = o.doc - 3;
  if (o.name === "button#export") updated.doc = 5;
  if (o.name.startsWith("button#sort") || o.name.startsWith("button.delete")) updated.doc = o.doc + 4;
  return updated;
});

console.log("");
report("filter panel moved earlier in source, tabindex removed:", FIXED);
```

```
filter panel comes after the table in source, tabindex=1 present:
  no  tab order                visual order
   1  button#export             a.skip-link
   2  a.skip-link               input#filter-code
   3  button#sort-date          input#filter-alert
   4  button#sort-temperature   button#filter
   5  button.delete[T-01]       button#export
   6  input#filter-code         button#sort-date
   7  input#filter-alert        button#sort-temperature
   8  button#filter             button.delete[T-01]
  inverted pair count: 13
    "a.skip-link" comes first visually, after "button#export" in tab order
    "input#filter-code" comes first visually, after "button#export" in tab order
    "input#filter-code" comes first visually, after "button#sort-date" in tab order
    "input#filter-code" comes first visually, after "button#sort-temperature" in tab order

filter panel moved earlier in source, tabindex removed:
  no  tab order                visual order
   1  a.skip-link               a.skip-link
   2  input#filter-code         input#filter-code
   3  input#filter-alert        input#filter-alert
   4  button#filter             button#filter
   5  button#export             button#export
   6  button#sort-date          button#sort-date
   7  button#sort-temperature   button#sort-temperature
   8  button.delete[T-01]       button.delete[T-01]
  inverted pair count: 0
```

Thirteen inverted pairs occur on a page of eight elements. The number is the product of
two mistakes: the positive `tabindex` value alone produces seven pairs, and the wrong
placement in source order produces the rest. The second report shows the direction of the
fix — the order is corrected in the **source**, not in the presentation layer. Because
the presentation layer determines the visual order, there is no consistent way other than
making the source match it.

The inverted-pair count can be used as an audit measure: any value greater than zero
means at least one user jumps backward on screen. If the delete button on every row of
the measurement table lengthens the tab order, the roving tabindex pattern established in
the Browser and the Web Platform course reduces the group to a single stop; this shortens
the sequence without disturbing the order.

## Focus Containment and the Trap

When the measurement entry form opens as a dialog, the page behind it must not remain
accessible: the tab key must cycle within the dialog. This arrangement is called **focus
containment**, and when set up incorrectly it turns into a trap that fails criterion
2.1.2. The script below models three setups as a state machine and audits each one by
exploring which states are reachable.

```js
// focus-trap.mjs — state machine for focus containment and keyboard-trap audit (2.1.2)

const BACKGROUND = ["input#filter-code", "button#filter", "button#new-measurement", "button.delete[T-01]"];
const DIALOG = ["input#date", "input#value", "button#save", "button#close"];
const KEYS = ["Tab", "Shift+Tab", "Escape"];

// type: "unconstrained" | "no-exit" | "correct"
function machine(type) {
  const hasExit = type !== "no-exit";
  return (d, key) => {
    if (!d.open) {
      const i = BACKGROUND.indexOf(d.focus);
      if (key === "Tab") return { open: false, focus: BACKGROUND[(i + 1) % BACKGROUND.length] };
      if (key === "Shift+Tab") return { open: false, focus: BACKGROUND[(i + BACKGROUND.length - 1) % BACKGROUND.length] };
      return d;
    }
    const ring = type === "unconstrained" ? [...DIALOG, ...BACKGROUND] : DIALOG;
    const i = ring.indexOf(d.focus);
    if (key === "Tab") return { open: true, focus: ring[(i + 1) % ring.length] };
    if (key === "Shift+Tab") return { open: true, focus: ring[(i + ring.length - 1) % ring.length] };
    if (key === "Escape" && hasExit) return { open: false, focus: "button#new-measurement" };
    return d;
  };
}

const key = (d) => (d.open ? "open" : "closed") + "|" + d.focus;

function check(type) {
  const next = machine(type);
  const start = { open: true, focus: DIALOG[0] };
  const seen = new Map([[key(start), start]]);
  const queue = [start];
  while (queue.length > 0) {
    const d = queue.shift();
    for (const k of KEYS) {
      const y = next(d, k);
      if (seen.has(key(y))) continue;
      seen.set(key(y), y);
      queue.push(y);
    }
  }
  const states = [...seen.values()];
  return {
    reached: states.length,
    leak: states.some((d) => d.open && BACKGROUND.includes(d.focus)),
    exit: states.some((d) => !d.open),
  };
}

console.log("setup          states reached  leak to background  keyboard exit  2.1.2");
for (const type of ["unconstrained", "no-exit", "correct"]) {
  const s = check(type);
  console.log(
    type.padEnd(14) +
      String(s.reached).padStart(14) +
      (s.leak ? "  yes" : "  no").padEnd(18) +
      (s.exit ? " yes" : " no").padEnd(17) +
      (s.exit ? "passed" : "FAILED"),
  );
}

// Key sequence in the correct setup
console.log("\nfocus trace in the correct setup:");
let d = { open: true, focus: DIALOG[0] };
const next = machine("correct");
console.log("  " + "start".padEnd(18) + " " + "dialog open".padEnd(14) + " focus: " + d.focus);
for (const k of ["Tab", "Tab", "Tab", "Tab", "Shift+Tab", "Escape", "Tab"]) {
  d = next(d, k);
  console.log(`  ${k.padEnd(18)} ${(d.open ? "dialog open" : "dialog closed").padEnd(14)} focus: ${d.focus}`);
}
```

```
setup          states reached  leak to background  keyboard exit  2.1.2
unconstrained             12  yes              yes             passed
no-exit                    4  no               no              FAILED
correct                    8  no               yes             passed

focus trace in the correct setup:
  start              dialog open    focus: input#date
  Tab                dialog open    focus: input#value
  Tab                dialog open    focus: button#save
  Tab                dialog open    focus: button#close
  Tab                dialog open    focus: input#date
  Shift+Tab          dialog open    focus: button#close
  Escape             dialog closed  focus: button#new-measurement
  Tab                dialog closed  focus: button.delete[T-01]
```

The three rows separate three distinct defects. The **unconstrained** setup passes the
criterion — an exit exists — but while the dialog is open, focus leaks to the four stops
in the background. The user focuses on elements they cannot see, covered by the dialog;
this is not a conformance failure, but a defect that breaks usability. The **no-exit**
setup reaches only four states: there is no way out of the dialog at all. This is a
keyboard trap by definition, and it fails criterion 2.1.2. The **correct** setup reaches
all eight states, with no leak and an exit.

The last two lines of the focus trace show a separate rule: when the dialog closes, focus
returns to the button that opened it. If it does not, focus falls to the root of the
document and the user starts navigating from the very beginning. This behavior was
established in the Browser and the Web Platform course as the responsibility that arises
when a program moves focus; here it becomes a requirement of the criterion.

Containment itself is not set up in only one way. Capturing the tab key and cycling focus
around a ring is one way; hiding the content outside the dialog from the tree and taking
it out of interaction is another. The second is preferred, because it establishes
containment not only against the tab key but also against the screen reader's own
navigation commands — in a dialog where only the tab key is captured, a user can jump to
the background from the landmark list.

## Focus Visibility

In an interface operated by keyboard, the location of focus must be visible at every
moment; criterion 2.4.7 requires this. Removing the focus indicator from the presentation
layer fails the criterion in a single line. The indicator can be changed: its thickness,
color, and shape are design decisions. It cannot be removed.

There are two additional conditions. The indicator must carry enough contrast to be
distinguished from adjacent colors — this is the subject of the non-text contrast
criterion and will be computed in the contrast lesson. A focused element must also **not
remain obscured**: if a bar stuck to the bottom of the page or a header pinned to the top
completely covers the focused element, the indicator exists but is not visible.

## Shortcuts

Writing a single-letter shortcut to jump to the filter field on the measurement list
seems reasonable. Criterion 2.1.4 imposes at least one of three conditions on shortcuts
like this: the shortcut must be possible to turn off, it must be possible to remap so the
user uses it together with a modifier key, or it must be active only while the relevant
component has focus.

The rationale shows up in two usage patterns. Surrounding speech can be transcribed for a
user dictating text, and single letters get interpreted as shortcuts; a user who has
difficulty pressing a single key on the keyboard triggers a shortcut unintentionally. On
the measurement entry form, this means lines get deleted while typing in the description
field.

The workable decision is this: single-character shortcuts are bound to the focused
component, not to the document as a whole. Row navigation with arrow keys in the
measurement table works while the table has focus; when the table does not have focus,
the same keys scroll the page. Shortcuts bound to the document as a whole are written
with a modifier key, and the user is left a way to turn them off.

## Summary

- 2.1.1 requires every function to be operable from a keyboard interface without
  depending on keystroke timing; 2.1.2 requires every place entered to be exitable
  through the same interface.
- Tab order comes from the source, visual order from the presentation layer; a divergence
  produces inverted pairs, and the fix is made in the source, not the presentation layer.
- Positive `tabindex` values push ahead of the whole order by themselves, raising the
  inverted-pair count on their own.
- Focus containment gives different results across three setups: an unconstrained setup
  lets focus leak to the background, a no-exit setup is a keyboard trap, and a correct
  setup returns focus to the control that opened it on close.
- The focus indicator can be changed in form but not removed; it must also carry enough
  contrast and must not be obscured by another element.
- Single-character shortcuts must be possible to turn off, to remap, or active only while
  the component has focus.

## Next Step

This lesson computed where focus goes but did not ask what the user **learns** once focus
arrives there. How is it announced that a new context has opened when the dialog opens?
When the filter is applied, how does the number of rows remaining in the table reach a
user who cannot see the screen? The next lesson takes up the announcement mechanisms: the
polite/assertive distinction in live regions, the queuing of announcements, and the
navigation planes that landmarks and headings form.
