Keyboard and focus
focus and blur, tabindex, focus traps, and the rules of keyboard navigation people don't get told.
A page works on a phone, with a mouse, with a screen reader, and with a keyboard alone. The keyboard is the one most beginners forget about — and the one that, broken, locks out a real chunk of the user base. Power users prefer it. People with motor disabilities depend on it. Screen readers run on top of it.
This lesson is about the small set of rules that make the keyboard work. Focus is the thing the keyboard cares about, so we start there.
What focus is
At any moment, exactly one element on the page is focused. That is the element keystrokes go to. If a text input is focused, your typing lands in it. If a button is focused, pressing Enter clicks it. If a link is focused, pressing Enter follows it.
You can see which element is focused with document.activeElement.
document.querySelector('#email').focus();
console.log(document.activeElement);
// the email input — same as the one aboveThe browser also fires events when focus moves: focus when an element gains focus, blur when it loses focus. (Lesson 6 mentioned that those do not bubble, and focusin / focusout are the bubbling versions.)
Most user actions move focus on their own. Click an input — it focuses. Press Tab — focus moves to the next focusable thing on the page. Click outside — the previous element blurs. You only have to manage focus yourself when the user does something the browser does not naturally cover (e.g. a "Skip to content" link, opening a custom popover, recovering from a deleted item).
The CSS pseudo-class :focus-visible is the one to use for focus rings — it shows the ring only when the user is navigating with the keyboard, not on every mouse click. Keep it visible. Removing focus rings is the most common accessibility break.
tabindex — three values, three meanings
By default, only certain elements are focusable — links with href, buttons, form controls, and a few others. Everything else (a <div>, a <span>, a custom card) is not in the keyboard tab order. Pressing Tab skips over them.
The tabindex attribute lets you change that. Three values you should know:
tabindex="0"— the element joins the natural tab order. Tab reaches it in document order, after the previous focusable element.tabindex="-1"— the element is focusable from JavaScript (element.focus()) but is not in the tab order. The user cannot Tab to it. This is the value you use when you need to move focus programmatically to a wrapper or a section.tabindex="1"(and other positives) — moves the element to the front of the tab order, ahead oftabindex="0"elements. Don't use these. Positive tabindex re-orders the tab path away from document order, and that almost always confuses screen-reader users.
<!-- An interactive card you built out of a div: --> <div class="card" tabindex="0" role="button">View details</div> <!-- A wrapper you only focus from JS (e.g. when content swaps): --> <section id="results" tabindex="-1">…</section>
A custom widget that takes Enter, like a card-as-button or a custom select, needs both tabindex="0" (so users can Tab to it) and a keydown handler (so Enter does something). It also needs a role so assistive tech announces it correctly. Lesson 9 covers role in more detail.
<div> that opens a popup. Mouse users can click it. Keyboard users cannot reach it. What is the smallest fix that puts it in the tab order?Moving focus from code
Two methods move focus by hand:
element.focus()— gives focus to the element. The element must be focusable (a real form control, link, or anything withtabindex).element.blur()— removes focus from it. Mostly useful in special cases — closing a dropdown, dismissing a hint.
The classic moment to move focus: when the page changes in a way the keyboard user needs to follow. If the user submits a form and a success panel appears, focus the panel — otherwise the keyboard user has no idea it showed up.
async function onSave(form) {
await fetch('/save', { method: 'POST', body: new FormData(form) });
const panel = document.querySelector('#success');
panel.hidden = false;
panel.focus(); // requires tabindex="-1" on the panel
}Why tabindex="-1" on the panel? Because a <section> is not naturally focusable. Setting tabindex="-1" makes it focusable from JS without putting it in the tab order. The user lands there, and the next Tab continues into the panel's content as expected.
Don't move focus during routine typing or scrolling. The user did not ask for it; you will pull them out of context. Move focus on big page-level events: opening a dialog, navigating to new content, recovering from an error.
Focus traps
A modal dialog should keep focus inside it. If you Tab past the last button, focus should loop back to the first — not escape into the page behind. Same on Shift+Tab from the first to the last. That loop is called a focus trap.
The <dialog> element from the previous lesson handles this for you when you call showModal(). The browser puts focus into the dialog, makes the rest of the page inert, and loops focus inside.
If you have to build a modal without <dialog> (legacy code, older browsers), the rough recipe is:
- Remember the element that was focused before the modal opened.
- Move focus into the modal (typically the first focusable child).
- On Tab from the last focusable element, send focus back to the first. On Shift+Tab from the first, send it to the last.
- On Escape or close, restore focus to the element from step 1.
// Sketch only — use <dialog> if you can.
const focusables = modal.querySelectorAll(
'a[href], button, input, select, textarea, [tabindex="0"]'
);
const first = focusables[0];
const last = focusables[focusables.length - 1];
modal.addEventListener('keydown', (event) => {
if (event.key !== 'Tab') return;
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
});That sketch shows why the native <dialog> is worth the switch — it handles the trap, the inert background, the Escape, and the focus restoration. Reach for it first.
Keyboard events without surprises
Three events fire as keys move:
keydown— the user pressed the key down. Fires once on press; can fire repeatedly if they hold the key (browser autorepeat).keyup— the user released the key. Fires once.keypress— deprecated. Don't use it.
For most "did the user press X?" code, listen on keydown. The event has two properties for identifying the key:
event.key— the character or named key, like'a','Enter','Escape','ArrowLeft'. Use this for logic.event.code— the physical key, like'KeyA','Enter','Escape'. Use this only if you care about the physical layout (rare, mostly games).
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape') closeMenu();
if (event.key === '/' && !insideTextField()) focusSearch();
});A useful picture: event.key is "what the user typed". event.code is "which key on the keyboard". For an Escape press, both happen to be 'Escape'. For typing "a", key is 'a' (or 'A' if Shift is held); code is 'KeyA' regardless. Almost always you want key.
Don't assume event.key is one character. 'Enter', 'Escape', 'ArrowDown', 'Backspace' are all multi-character names for non-printable keys. Compare against the full string, not the first character.
document.addEventListener('keydown', e => e.code === 'Escape' && close()). It works on QWERTY. A user with a Dvorak layout reports it works for them too. Then a user with a French AZERTY keyboard says it works as well. What's the common thread that makes event.code the wrong-feeling but accidentally working choice here?section.focus() does nothing. The section has no tabindex. Why does focus refuse to land?