---
title: 'Focus and Keyboard Interaction'
source: 'https://academia.sh/en/courses/browser-platform/focus-and-keyboard-interaction'
course: 'The Browser and the Web Platform'
language: en
updated: '2026-08-17T18:09:12+00:00'
license: 'CC BY-SA 4.0'
---

# Focus and Keyboard Interaction

Determining the single element that receives input; focusability conditions, computing tab order, the bubbling behavior of focus events, the information key events carry, and a navigation layout leaving a single stop within a group.

The previous lesson said keyboard behaviors are among the behaviors that should not be
cancelled. The reason is this: not all interaction with the page is built with a pointing
device. The user moves between elements with the tab key, presses a button with space,
moves between options with the arrow keys; a screen reader follows the same path.

At the center of this movement is a single concept: at any moment, at most one element in
the document receives input. That element is called the **active element**, and it is the
target of events coming from the keyboard. This lesson establishes how the active element
is determined and what information keyboard events carry.

## Focusability

**Focus** is the authority to receive input. Not every element can take focus. Whether an
element can take focus depends on three conditions.

The first is **natural focusability**. Links, buttons, form fields, and controls take
focus; this requires no additional declaration. A section carrying text or a list only
gets focus when it is explicitly requested.

The second is **visibility**. An element hidden from the document cannot take focus; code
that tries to focus a hidden element silently fails. This is the source of focus getting
lost the moment an open panel closes.

The third is **being enabled**. A disabled control does not take focus; a keyboard user
never sees it at all. A design consequence follows from this: disabling a button can also
make its associated error message unreachable. The reason is not being able to stand on
the element.

An element that takes focus is required to have a visible marker. WCAG criterion 2.4.7
requires a focused element to have a visible indicator. Code that removes the focus
indicator from the style produces a situation similar to working with a cursor no one can
see.

## Tab Order

The tab key moves focus according to document order. This order can be changed with the
`tabindex` attribute, and the sign of the value produces three different behaviors.

```js
// tab.mjs — deriving tab order from document order and tabindex value
const elements = [
  { name: "a.skip-link",       tabindex: null, naturallyFocusable: true,  hidden: false, disabled: false },
  { name: "input#filter",      tabindex: null, naturallyFocusable: true,  hidden: false, disabled: false },
  { name: "button#applyFilter",tabindex: null, naturallyFocusable: true,  hidden: false, disabled: false },
  { name: "button#export",     tabindex: 1,    naturallyFocusable: true,  hidden: false, disabled: false },
  { name: "div#summary",       tabindex: -1,   naturallyFocusable: false, hidden: false, disabled: false },
  { name: "span.badge",        tabindex: null, naturallyFocusable: false, hidden: false, disabled: false },
  { name: "button.delete[T-01]", tabindex: null, naturallyFocusable: true, hidden: false, disabled: false },
  { name: "button.delete[T-02]", tabindex: null, naturallyFocusable: true, hidden: false, disabled: true  },
  { name: "button#submit",     tabindex: null, naturallyFocusable: true,  hidden: true,  disabled: false },
  { name: "div.row-container", tabindex: 0,    naturallyFocusable: false, hidden: false, disabled: false },
];

const focusable = (o) =>
  !o.hidden && !o.disabled && (o.naturallyFocusable || o.tabindex !== null);
const inTabOrder = (o) => focusable(o) && (o.tabindex === null ? true : o.tabindex >= 0);

const tabOrder = (list) => {
  const documentOrder = list.map((o, i) => ({ ...o, i })).filter(inTabOrder);
  const positive = documentOrder.filter((o) => (o.tabindex ?? 0) > 0)
    .sort((a, b) => a.tabindex - b.tabindex || a.i - b.i);
  const zero = documentOrder.filter((o) => (o.tabindex ?? 0) === 0);
  return [...positive, ...zero];
};

console.log("tab order:");
for (const [n, o] of tabOrder(elements).entries())
  console.log(`  ${n + 1}. ${o.name.padEnd(20)} tabindex=${o.tabindex ?? "none"}`);

console.log("not in order:");
for (const o of elements.filter((o) => !inTabOrder(o))) {
  const reason = o.hidden ? "hidden" : o.disabled ? "disabled"
    : o.tabindex === -1 ? "tabindex=-1 (only focusable via script)" : "not focusable";
  console.log(`  ${o.name.padEnd(20)} ${reason}`);
}
```

```
tab order:
  1. button#export        tabindex=1
  2. a.skip-link          tabindex=none
  3. input#filter         tabindex=none
  4. button#applyFilter   tabindex=none
  5. button.delete[T-01]  tabindex=none
  6. div.row-container    tabindex=0
not in order:
  div#summary          tabindex=-1 (only focusable via script)
  span.badge           not focusable
  button.delete[T-02]  disabled
  button#submit        hidden
```

Three rules are visible in the output.

**A value of zero places the element in the order without changing its position.** A
container that does not naturally take focus becomes a tab stop this way and keeps its
place in document order.

**A negative value takes the element out of the order but leaves it focusable.** The tab
key does not pass over it; a program can still move focus there. This is what makes it
possible to give focus to an error summary or an open panel.

**Positive values break the order.** The output's first line shows this: the export button
is fourth in the document, first in tab order. The page's skip link drops to second place.
Positive values come before every other stop in the document; every component added to
the page later has to rethink this ordering. The meaningful order WCAG criterion 2.4.3
asks for is achieved by getting the markup's own order right — not with positive values.

## Focus Events and Programmatic Focus

Focus change produces four events, defined as two pairs. As noted in the Event Model
lesson, focus-gain and focus-loss events **do not bubble**; a bubbling pair carrying the
same information is defined separately. Code that wants to watch focus change across
every field of a form with a single listener has to either use the bubbling pair or
register in capture mode; the rule set in the Event Delegation lesson applies here.

Focus events report both the element **giving up** and the element **receiving** focus.
When a field is left, where focus went is read from this information, and two cases are
distinguished: focus has moved to another field within the form, or the form has been left
entirely. Running field validation only in the second case spares the user from being met
with an error message at every field transition.

Two responsibilities arise when a program moves focus. First, the element focus moves to
needs to be visible; the browser scrolls the focused element into the visible area, and
this scroll can be perceived as a page jump. Second, **focus must not be lost**: when a
focused element is removed from the tree, focus drops to the document's root and the tab
key starts its cycle over from the beginning. Code that deletes a row has to decide where
focus should go before deleting — to the neighboring row, or to the list itself.

A third case requires **containing** focus. An open dialog blocks tabbing through to the
page behind it; focus circles inside the dialog. WCAG criterion 2.1.2 places a bound while
this arrangement is being built: a user who entered by keyboard must be able to leave by
keyboard. A focus trap with no way out is an accessibility bug.

## Key Events

A key press produces two events: when the key is pressed and when it is released. A key
held down produces new press events at the operating system's repeat interval; the repeat
flag in the event object tells these events apart from the first press. A keyboard
shortcut that increments a counter reads this flag depending on whether it wants to count
the repeats.

The event object reports the key with two separate fields, and the distinction between
them is critical.

**The meaning field** gives the value the key produces: `a`, `Enter`, `ArrowDown`,
`Escape`. It changes according to the user's keyboard layout and held modifiers; uppercase
and lowercase are different values.

**The position field** gives the key's physical place on the keyboard. It stays the same
even if the layout changes.

The selection rule is: if a shortcut relies on a letter's meaning, the meaning field is
read; if it relies on the key's place under the hand, the position field is read. Code
relying on the meaning field lands on different keys on different layouts; code relying on
the position field may not match the letter the user sees.

Deriving text input from key events is a separate mistake. In the **composition**
mechanism used for Chinese, Japanese, and Korean input, the user presses more than one key
to produce a single character; the field's content does not match the keys during the
intermediate stage. Likewise, pasting, voice input, and autofill produce no key event. The
only reliable way to learn a field's content is listening to input events; this is the
subject of the Form Events lesson.

## Group-Internal Navigation

If every row of the measurement table has a button, the tab key walks through every row
one by one; in a hundred-row table, leaving the table takes a hundred key presses. The
established solution is making the group a single tab stop and navigating within the
group with the arrow keys. This layout is called **roving tabindex**: at any moment, a
single element in the group has a `tabindex` value of zero, the rest are negative one; the
arrow key carries both the focus and the zero value.

```js
// roving.mjs — focus management that leaves a single tab stop within a group
const rows = ["T-01", "T-02", "T-03", "T-04"];
let active = 0;          // the group's single tab stop
let focus = "outside group"; // the element currently holding focus

const stops = () => rows.map((name, i) => `${name}:${i === active ? 0 : -1}`).join(" ");

function key(name) {
  const result = { key: name, prevented: false };
  if (focus === "outside group") {
    if (name === "Tab") focus = rows[active];   // entering the group: the browser's own behavior
    return result;
  }
  switch (name) {
    case "ArrowDown": active = (active + 1) % rows.length; break;
    case "ArrowUp":   active = (active - 1 + rows.length) % rows.length; break;
    case "Home":      active = 0; break;
    case "End":       active = rows.length - 1; break;
    case "Tab":       focus = "outside group"; return result;   // default behavior is kept
    default:          return result;                             // no work done, no cancellation requested
  }
  focus = rows[active];
  result.prevented = true;   // the arrow keys' scrolling behavior is cancelled
  return result;
}

console.log("start:", focus.padEnd(14), "|", stops());
for (const name of ["Tab", "ArrowDown", "ArrowDown", "End", "Home", "ArrowUp", "x", "Tab"]) {
  const result = key(name);
  console.log(
    result.key.padEnd(9),
    "focus:", focus.padEnd(14),
    "| prevented:", String(result.prevented).padEnd(5),
    "|", stops(),
  );
}
```

```
start: outside group  | T-01:0 T-02:-1 T-03:-1 T-04:-1
Tab       focus: T-01           | prevented: false | T-01:0 T-02:-1 T-03:-1 T-04:-1
ArrowDown focus: T-02           | prevented: true  | T-01:-1 T-02:0 T-03:-1 T-04:-1
ArrowDown focus: T-03           | prevented: true  | T-01:-1 T-02:-1 T-03:0 T-04:-1
End       focus: T-04           | prevented: true  | T-01:-1 T-02:-1 T-03:-1 T-04:0
Home      focus: T-01           | prevented: true  | T-01:0 T-02:-1 T-03:-1 T-04:-1
ArrowUp   focus: T-04           | prevented: true  | T-01:-1 T-02:-1 T-03:-1 T-04:0
x         focus: T-04           | prevented: false | T-01:-1 T-02:-1 T-03:-1 T-04:0
Tab       focus: outside group  | prevented: false | T-01:-1 T-02:-1 T-03:-1 T-04:0
```

Four behaviors read from the output. Entering the group is the browser's own tab
behavior, and it is not cancelled. The arrow keys carry focus, and cancellation is
requested only for these keys; the arrow keys' page-scrolling behavior has been replaced
with group-internal navigation. The `x` line before the last shows that cancellation is
not requested for keys that are not handled — typing, shortcuts, and browser behaviors are
unaffected. The last tab key leaves the group, and the group's stop stays on the
most-recently-used row; the user picks up where they left off when they return.

## Summary

- At any moment, at most one element in the document receives input; focusability depends
  on natural focus-taking, visibility, and being enabled.
- A `tabindex` of zero places an element in order at its document position, a negative
  value takes it out of order but leaves it open to programmatic focus, positive values
  cut ahead of the entire order and break the layout.
- Focus events' non-bubbling pair is only seen at the target; delegation uses the
  bubbling pair or capture mode. Events report both the element giving up and the one
  receiving focus.
- If a focused element is removed from the tree, focus starts over from the root; delete
  and close operations need to decide ahead of time where focus should go.
- Key events report meaning and physical position in separate fields; text input cannot
  be derived from key events.
- In the roving tabindex layout, there is a single stop in the group, the arrow keys carry
  both focus and the stop together, and the default behavior is kept for unhandled keys.

## Next Step

This lesson said the only reliable way to learn text input is not key events, and left the
real way for later. The form on the page does not only collect text: a measurement value
is a number, a date comes from a calendar control, a station code is chosen from a list.
Every control reports its value at different moments — some at every keystroke, some when
the field is left. On top of this there is submission: the browser collects the fields,
tests the constraints, and builds the request. The next lesson covers this flow, which
event reports which moment, and where validation belongs.
