Keyboard & focus
Handle keyboard events, focus movement, focus traps, disclosures, and live regions without breaking accessibility.
- 01Read keyboard eventsUse
keydown,keyup,key,code, modifiers, repeat, and composition correctly. - 02Manage focusExplain focus order,
tabindex, bubbling focus events,activeElement, andfocus({ preventScroll }). - 03Build accessible controlsTrap focus in dialogs, return focus to the opener, and announce changes with
aria-expandedand live regions.
Keyboard access is real access
A pointer can click what it sees. A keyboard moves through the page one focused control at a time, then sends key events to that focused place. If your interface cannot be reached, opened, escaped, and understood from a keyboard, many people cannot use it comfortably at all.
This lesson connects two systems that work together: keyboard events tell you what happened on the keyboard, and focus tells you which element receives those events. We will build inspectors, shortcuts, logs, a modal trap, and a live status widget using the browser’s real behavior.
Think of focus as the page’s spotlight. Keyboard events go to the actor in the light. Tab and Shift+Tab move the light. A modal dialog is a smaller stage: the spotlight must stay there until the scene is over.
- In real life: One actor is lit
- In JavaScript: One element is
document.activeElement - In real life: Tab moves the spotlight
- In JavaScript: Normal focus order follows readable, focusable controls
- In real life: A dialog keeps the scene contained
- In JavaScript: A focus trap keeps Tab inside until the dialog closes
- In real life: The spotlight returns to the opener
- In JavaScript: After closing, focus should go back to the button that opened it
Where the analogy stops: Real spotlights can light several actors at once. Browser focus normally has one active element per document, with extra details for shadow trees and iframes.
Keyboard and focus code handles keys according to the element that currently has focus, while preserving native text entry, Tab order, Escape, and assistive-technology announcements.
keydown and keyup
INTERACTIVEThe browser fires keydown when a key goes down and keyup when it comes back up. If you hold a key, most browsers repeat keydown; repeated events have event.repeat set to true. The matching keyup fires once when you release.
The old keypress event is deprecated. It mixes “a key was pressed” with “text might be typed” and does not handle modern input well. For text entry, listen to input. It covers typing, paste, autocomplete, mobile keyboards, and input method editors.
box.addEventListener("keydown", (event) => { if (event.key === " " || event.key === "ArrowDown") event.preventDefault(); show({ key: event.key, code: event.code, repeat: event.repeat, shiftKey: event.shiftKey, ctrlKey: event.ctrlKey, altKey: event.altKey, metaKey: event.metaKey, capsLock: event.getModifierState("CapsLock"), isComposing: event.isComposing, });}); box.addEventListener("keyup", showRelease);// `keypress` is deprecated; use `input` for text and `keydown`/`keyup` for keys.Focus the box, then press keys.
tabindex="0". Space and ArrowDown are prevented only inside the box so the page does not scroll while you inspect them. IME composition depends on your keyboard/input method.Notice the modifier flags: shiftKey, ctrlKey, altKey, and metaKey. Caps Lock is different: ask event.getModifierState("CapsLock"). During IME text composition, event.isComposing can be true, so shortcut code should avoid treating those keystrokes like final text.
key vs code
STEP THROUGHevent.key is the meaning of the key for the user’s current layout: "a", "A", "Enter", "ArrowLeft", " " for Space, or sometimes "Dead" for a dead key waiting to combine with another character. event.code names the physical seat: values like "KeyA", "Digit1", "Space", and "ShiftLeft".
The key is like the letter printed on the keycap for the current layout. The code is the chair that key occupies on the keyboard.
- In real life: Printed keycap
- In JavaScript:
event.key: what the user meant in this layout - In real life: Physical seat on the board
- In JavaScript:
event.code: where the key sits - In real life: French layout changes the printed letter
- In JavaScript: QWERTY Q position can report
keyasabutcodeasKeyQ - In real life: Game controls may care about a seat
- In JavaScript: WASD-style movement often prefers
code
Where the analogy stops: The browser cannot always know unusual hardware perfectly, and mobile keyboards often do not map to physical seats at all. For text, prefer the input event.
Replay a keyboard shortcut handler that respects focused text fields.
script
const event = { key: "k", metaKey: true, ctrlKey: false, repeat: false };const editingText = target === "input";const commandKey = event.metaKey || event.ctrlKey;const opensPalette = commandKey && event.key.toLowerCase() === "k" && !editingText;console.log(opensPalette ? "open palette" : "let the browser type");Shortcuts should be scoped. This lesson’s command palette listens only inside its demo surface, handles Command on macOS and Ctrl elsewhere, and calls preventDefault() only for the exact shortcut it owns.
demo.addEventListener("keydown", (event) => { const editing = event.target.matches("input, textarea, [contenteditable]"); const command = event.metaKey || event.ctrlKey; if (command && event.key.toLowerCase() === "k" && !editing) { event.preventDefault(); openPalette(); }});Focus the demo surface and press Ctrl+K or Command+K. Then try the input: typing wins there.
- A shortcut labeled
Ctrl+Z - Detecting Enter to submit a search
- A WASD game that cares about physical positions
- A browser piano using the row
KeyAthroughKeyL - Typing a person's name into a field
- Counting characters in a comment box
Sort each scenario by the event signal you should reach for first.
Focus, blur, focusin and focusout
INTERACTIVEFocus is not only a visual ring. It is the browser’s routing table for keyboard input. Native controls such as links, buttons, inputs, selects, and textareas are focusable. A custom element can join normal Tab order with tabindex="0". Use tabindex="-1" for elements you will focus only with code. Positive tabindex values are discouraged because they create a surprising order.
| Event | Bubbles? | Good use |
|---|---|---|
| focus | No | React to one element receiving focus, or listen in capture. |
| blur | No | React to one element losing focus, or listen in capture. |
| focusin | Yes | Log or delegate focus changes from a container. |
| focusout | Yes | Log or delegate focus leaving a container. |
container.addEventListener("focus", log, true);container.addEventListener("blur", log, true);container.addEventListener("focusin", log);container.addEventListener("focusout", log); laterButton.focus({ preventScroll: true });console.log(document.activeElement);document.activeElement is (none yet). Tab through the controls and compare non-bubbling focus/blur with bubbling focusin/focusout.
relatedTarget can be null when focus enters from, or leaves to, outside this document/window.document.activeElement tells you the active element after focus settles. relatedTarget is the element focus is moving from or to; it can be null when focus crosses document or browser boundaries. When you move focus with code, use element.focus({ preventScroll: true }) if scrolling would be disorienting.
Style focus clearly. Modern browsers expose :focus-visible so you can show a strong ring for keyboard focus without forcing the same treatment on every mouse click.
Focus traps and dialogs
INTERACTIVEA modal dialog interrupts the page. While it is open, Tab should cycle through the dialog’s focusable controls, Escape should close it unless there is a good reason not to, and focus should return to the opener. Otherwise keyboard users can fall behind the overlay or lose their place.
const focusables = [...dialog.querySelectorAll("button, input, [href]")];dialog.addEventListener("keydown", (event) => { if (event.key === "Escape") closeDialog(); if (event.key !== "Tab") return; const first = focusables[0]; const last = focusables.at(-1); if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus(); } else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus(); }});The modal is closed. Focus should be on the opener after closing.
Open the modal. A native <dialog>.showModal() gives you browser-managed inertness; this demo shows the manual trap logic you must get right when hand-building one.
<dialog> when it fits. Hand-built traps also need background inertness, labelling, Escape handling, and focus return.| Choice | What it gives you | What to still check |
|---|---|---|
dialog.showModal() | Makes outside page content inert in supporting browsers and handles Escape through a cancel event. | Label the dialog, place useful initial focus, and explicitly return focus because browser details can differ. |
| Manual trap | Works for custom overlays when native dialog is not a fit. | Cycle Tab/Shift+Tab, handle Escape, set background inert, label the dialog, and restore focus. |
const focusables = ["open", "cancel", "save"];let activeIndex = 0;function moveFocus(direction) { activeIndex = (activeIndex + direction + focusables.length) % focusables.length; return focusables[activeIndex];}console.log(moveFocus(1));console.log(moveFocus(-1));aria-expanded and aria-live
INTERACTIVEWhen a button opens and closes a panel, keep the relationship visible to assistive technology: put aria-expanded on the button and point aria-controls at the panel. The button still needs real button behavior; ARIA describes state, it does not add click or keyboard behavior by itself.
A live region is like an announcer who speaks important updates while the spotlight stays on the current control.
- In real life: Announcer reads score updates
- In JavaScript: A live region announces changed text
- In real life: Fans keep watching the field
- In JavaScript: Focus does not move to the status
- In real life: Routine updates wait for a pause
- In JavaScript:
aria-live="polite"orrole="status" - In real life: Emergency announcement interrupts
- In JavaScript:
assertiveorrole="alert"for urgent messages
Where the analogy stops: Screen readers and settings vary. The standard intent is announcement without focus movement; do not use live regions as a substitute for testing with assistive technology.
button.setAttribute("aria-expanded", String(open));button.setAttribute("aria-controls", "filter-panel");panel.hidden = !open; status.textContent = `3 results found`;// The live region existed before this text changed.- Buttons
- Dialogs
- Live regions
The button says aria-expanded=false. The live status text is present from the start and now says: 3 results found.
role="status" is usually polite. Use role="alert" or assertive live regions only for urgent messages.Put the live region in the document before changing its text. Use polite for ordinary updates such as “3 results found.” Use assertive live regions or role="alert" sparingly for urgent information because the standard intent is to interrupt.
Where you will use this
Keyboard and focus details show up in almost every serious interface: search boxes that submit on Enter, menus that close on Escape, command palettes, skip links, form validation summaries, disclosure panels, autocomplete lists, and modals.
const target = "page";const event = { key: "k", metaKey: true, ctrlKey: false, repeat: false };const editingText = target === "input";const commandKey = event.metaKey || event.ctrlKey;const opensPalette = commandKey && event.key.toLowerCase() === "k" && !editingText;console.log(opensPalette ? "open palette" : "let the browser type");- Use
keydownfor commands, not text validation. - Check the focused target before stealing a shortcut.
- Prevent the browser default only for the key combination you own.
- Return focus after temporary UI closes.
Common misconceptions
- “keypress is the text event.” It is deprecated; use
inputfor text changes. - “key and code are interchangeable.”
keyfollows meaning and layout;codefollows physical position. - “A div with click is a button.” A real
buttonalready has focus, Enter/Space behavior, disabled state, and semantics. - “focus and blur bubble.” They do not; use capture or the bubbling
focusin/focusoutpair. - “A modal is accessible once it is visually centered.” It also needs focus management, inert background, labeling, Escape behavior, and return focus.
- “Live regions are magic.” They communicate standard intent, but assistive technology behavior varies and must be tested.
Practice exercises
5 EXERCISESAnswer from memory: which keyboard event repeats while a key is held down?
keydown repeats while the key is held. Repeated events have event.repeat set to true; keyup fires once on release.
Run the snippet in your head. What exact string is logged first?
const event = { key: "Enter", repeat: false };
console.log(event.key);
console.log(event.repeat);const event = { key: "Enter", repeat: false };
console.log(event.key);
console.log(event.repeat);The first log is Enter; the second is false. Compare the exact named key string, not a lowercase guess.
Write or explain a helper that opens a command palette for Ctrl/Command+K but not while typing in an input.
function shouldOpenPalette(event, editingText) {
const command = event.metaKey || event.ctrlKey;
return command && event.key.toLowerCase() === "k" && !editingText;
}
console.log(shouldOpenPalette({ key: "k", metaKey: true, ctrlKey: false }, false));
console.log(shouldOpenPalette({ key: "k", metaKey: true, ctrlKey: false }, true));function shouldOpenPalette(event, editingText) {
const command = event.metaKey || event.ctrlKey;
return command && event.key.toLowerCase() === "k" && !editingText;
}
console.log(shouldOpenPalette({ key: "k", metaKey: true, ctrlKey: false }, false));
console.log(shouldOpenPalette({ key: "k", metaKey: true, ctrlKey: false }, true));The helper returns true only for Ctrl/Command+K outside text editing. Real event code would call preventDefault() only when the helper returns true.
A form section listens for focus on the container and expects to hear child inputs. Which bubbling event should it use instead?
Use focusin on the container, or listen to focus with capture. focusin bubbles, so one container listener can see focus entering child controls.
You made a custom panel that should be reachable with Tab in the normal reading order. What tabindex value does that?
Use tabindex="0" only when a custom element truly belongs in normal Tab order. For a modal title that receives initial programmatic focus, tabindex="-1" is often better; for this exercise’s custom box in normal order, the answer is 0.
Check your understanding
7 QUESTIONSQuestion 1 of 7What happens when a physical key is held down in most browsers?
Choose an answer to see the explanation.
Question 2 of 7What does this print for
event.key?Read the code, then predictconst event = { key: "Enter", code: "NumpadEnter" }; console.log(event.key);Choose an answer to see the explanation.
Question 3 of 7On a French AZERTY keyboard, pressing the physical QWERTY Q position may produce
key: "a"andcode: "KeyQ". Which property tells what the user meant to type?Choose an answer to see the explanation.
Question 4 of 7Which event should you prefer for text entry in an input?
Choose an answer to see the explanation.
Question 5 of 7Which focus events bubble from children to a container?
Choose an answer to see the explanation.
Question 6 of 7What does this focus-wrap snippet print?
Read the code, then predictconst focusables = ["cancel", "save"]; let index = 0; index = (index - 1 + focusables.length) % focusables.length; console.log(focusables[index]);Choose an answer to see the explanation.
Question 7 of 7Which statement about live regions is accurate?
Choose an answer to see the explanation.
Key takeaways
keydownstarts a key action, can repeat, and is good for commands;keyupmarks release.keymeans “what the user intended”;codemeans “which physical key seat.”- Use
inputfor text changes, especially for IME, paste, mobile keyboards, and autocorrect. focus/blurdo not bubble;focusin/focusoutdo.- Modals must keep focus inside, support Escape when appropriate, make the background inert, and return focus.
aria-expandeddescribes disclosure state; live regions announce updates without moving focus.
Keyboard-friendly JavaScript lets the focused element receive meaningful key commands while focus management and ARIA keep the interface reachable and understandable.
Up next: Page & resource loading.