Keyboard & focus
Handle keyboard events, focus movement, focus traps, disclosures, and live regions without breaking accessibility.
- 01Read key eventsUse
keydownandkeyup, tellkeyfromcode, and check modifier keys, repeats, and IME typing. - 02Move focusExplain the Tab order,
tabindex, which focus events bubble,document.activeElement, andfocus({ preventScroll: true }). - 03Build controls everyone can useKeep focus inside a dialog, send it back to the opener, and tell screen readers about changes with
aria-expandedand live regions.
Using a page with only the keyboard
Many people use a page with only the keyboard. Some can't use a mouse. Others just find the keyboard faster. A keyboard user can't click on things. Instead, they press Tab to move from one control to the next. A control is anything you can use on a page, like a button, a link, or a text box.
The control that is ready for the keyboard right now has focus. Only one element has focus at a time. When you press a key, the browser sends a key event to that element. So this lesson is about two things that work together:
- Key events tell you which key was pressed.
- Focus tells you which element gets those key events.
If people can't reach a control, open it, close it, and understand it with only the keyboard, they can't use it. In this lesson you'll try a key inspector, a shortcut, a focus log, a dialog that keeps focus inside, and a message that is read aloud.
Think of a theater stage with one spotlight. The audience listens to the actor in the light. The spotlight moves from actor to actor. Sometimes a small scene plays in one corner, and the light stays in that corner until the scene ends.
- In real life: One actor stands in the spotlight
- In JavaScript: One element has focus. JavaScript calls it
document.activeElement - In real life: The spotlight moves to the next actor
- In JavaScript: Tab moves focus to the next control
- In real life: A small scene keeps the spotlight in one corner
- In JavaScript: An open dialog keeps focus inside it
- In real life: The spotlight goes back to the actor who started the scene
- In JavaScript: When the dialog closes, focus goes back to the button that opened it
Keyboard and focus code reacts to keys on the element that has focus. It must also keep these things working: typing text, moving with Tab, closing with Escape, and the messages that screen readers read aloud. A screen reader is a program that reads the page out loud, for people who can't see the screen.
keydown and keyup
INTERACTIVEWhen you press a key, the browser fires two events:
keydownfires when the key goes down.keyupfires when the key comes back up.
If you hold a key down, most browsers fire keydown again and again. Each extra event is a repeat. On a repeat, event.repeat is true. The keyup event fires only once, when you let go.
There is also an old event called keypress. It is deprecated: browsers keep it only for old code, and new code should not use it. To know when the text in a field changes, listen for the input event instead. It fires for every way text can change:
- typing on a keyboard
- pasting
- autocomplete, when the browser fills in a saved value
- phone keyboards and speech
- an input method editor (IME): a tool that builds one character from several key presses, used for languages like Chinese and Japanese
const event = { key: "Enter", repeat: false };
console.log(event.key);
console.log(event.repeat);This code uses a plain object that looks like a key event, so it can run anywhere. Line 1 makes the object. Line 2 prints the name of the key, Enter. Line 3 prints false, because this is the first event, not a repeat.
const field = { value: "Asha" };
field.value = "Asha Patel";
console.log(field.value);Line 1 makes an object that stands for a text field holding Asha. Line 2 changes the whole value at once. Line 3 prints Asha Patel. A real input event tells you the new value in the same way. It doesn't matter if the text came from one key, a paste, autocomplete, speech, or an IME.
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.Click the box, then press keys.
tabindex="0", so it can get focus. Inside the box, Space and ArrowDown don't scroll the page. What IME typing shows depends on your keyboard settings.Look at the modifier flags in the log. A modifier is a key you hold while you press another key:
shiftKeyistruewhile Shift is held.ctrlKeyistruewhile Ctrl is held.altKeyistruewhile Alt is held. On a Mac, this key is called Option.metaKeyistruewhile Command is held on a Mac.
Caps Lock works differently, because it stays on after you let go. To check it, call event.getModifierState("CapsLock"). While someone is building a character with an IME, event.isComposing is true. Shortcut code should ignore those key presses, because the person is still typing. A command that should run only once should also ignore events where event.repeat is true.
key and code: what it types, or where it sits
STEP THROUGHEvery key event has two names for the key:
event.keyis what the key types right now, with the person's keyboard layout. Examples:"a","A","Enter","ArrowLeft", and" "(one space) for the Space bar.event.codeis where the key sits on the keyboard, no matter what it types. Examples:"KeyA","Digit1","Space", and"ShiftLeft".
A keyboard layout is which letter goes on which key. It is different in different countries. On a French keyboard, the key where a US keyboard has Q types the letter a. So for that key, event.key is "a", but event.code is still "KeyQ".
a. event.key says what it types; event.code says where the key sits.Some layouts have dead keys. A dead key adds an accent to the next letter: press the accent key, then e, and you get é. When you press the dead key itself, event.key is "Dead", because nothing is typed yet.
Look at a keyboard. Each key has a letter printed on it, and each key sits in a spot. In another country, the same spot can have a different letter printed on it. key is the printed letter. code is the spot.
- In real life: The letter printed on the key
- In JavaScript:
event.key: what the person meant to type - In real life: The key's spot on the keyboard
- In JavaScript:
event.code: where the key sits - In real life: A French keyboard prints a different letter in the same spot
- In JavaScript: Same spot:
codeisKeyQ, butkeyisa - In real life: A game cares about the spot, not the letter
- In JavaScript: Games that move with W, A, S, and D often use
code
Step through a shortcut check that leaves text fields alone.
script
const event = { key: "k", metaKey: true, ctrlKey: false, repeat: false, isComposing: false };const editingText = target === "input";const commandKey = event.metaKey || event.ctrlKey;const opensPalette = commandKey && event.key.toLowerCase() === "k" && !editingText && !event.repeat && !event.isComposing;console.log(opensPalette ? "open palette" : "let the browser type");A shortcut is a key combination that runs a command, like Ctrl+K to open a search box. A good shortcut listens only inside the part of the page that owns it. The demo below opens a command palette: a small box where you type a command. It follows three rules:
- It listens only inside its own demo area.
- It accepts Command+K on a Mac and Ctrl+K everywhere else.
- It calls
preventDefault()only for that exact shortcut.preventDefault()stops the browser's own action for the key, so every other key keeps working as normal.
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.repeat && !event.isComposing) { event.preventDefault(); openPalette(); }});Click the demo area and press Ctrl+K or Command+K. Then try the same keys in the text field: there, the keys are left for typing.
- An undo shortcut,
Ctrl+Z - Search when someone presses Enter
- A game that moves with W, A, S, and D
- A web piano played on the keys from
KeyAtoKeyL - Someone types their name into a text field
- Count the letters in a comment box
For each case, pick what your code should check first.
Which elements get focus, and the four focus events
INTERACTIVEFocus is more than the ring you see around a control. It decides where key presses go. Some elements can get focus on their own: links, buttons, text fields, select menus, and text areas. Any other element needs the tabindex attribute:
tabindex="0"puts the element in the normal Tab order, in the order it appears on the page.tabindex="-1"lets your code focus the element, but Tab skips it.- A positive number, like
tabindex="3", makes Tab visit the element before the normal order. Avoid it, because the order becomes hard to predict.
When focus moves, the browser fires four events: focus, blur, focusin, and focusout. Only two of them bubble. Bubbling means the event also reaches the parent elements, so one listener on a container hears every child.
focusin bubbles up, so only it reaches a normal listener on the container.Each row is one focus event. Read across to see if it bubbles, and what it is good for.
| Event | Does it bubble? | What it is good for |
|---|---|---|
| focus | No. | React when one element gets focus. A container hears it only with a capture listener. |
| blur | No. | React when one element loses focus. A container hears it only with a capture listener. |
| focusin | Yes. | One listener on a container hears focus arrive on any child. |
| focusout | Yes. | One listener on a container hears focus leave any child. |
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). Press Tab to move through the controls. Compare focus and blur, which don't bubble, with focusin and focusout, which do.
relatedTarget is null when focus comes from, or goes to, somewhere outside this page.document.activeElement is the element that has focus right now. Check it after the focus move is done. Each focus event also has a relatedTarget: the other element in the move.
- For
focusandfocusin, it is the element that just lost focus. - For
blurandfocusout, it is the element that gets focus next. - It is
nullwhen focus comes from, or goes to, somewhere outside the page.
To move focus with code, call element.focus(). If the jump would scroll the page and confuse people, write element.focus({ preventScroll: true }) instead.
const tabOrder = { customBox: 0, dialogTitle: -1 };
console.log(tabOrder.customBox);
console.log(tabOrder.dialogTitle);This is a plain object, not the real page. Line 2 prints 0: a custom box that belongs in the normal Tab order. Line 3 prints -1: a dialog title that code can focus, but Tab skips.
Make focus easy to see. The CSS selector :focus-visible matches a focused element when the browser thinks a ring helps, mostly when someone uses the keyboard. So you can show a strong ring for keyboard users without showing it on every mouse click.
Keeping focus inside a dialog
INTERACTIVEA modal dialog is a box that opens on top of the page. The page behind it can't be used until the dialog closes. While the dialog is open, three things should be true:
- Tab and Shift+Tab move only between the controls inside the dialog. This is called a focus trap.
- Escape closes the dialog, unless there is a good reason not to.
- When the dialog closes, focus goes back to the button that opened it.
Without these, a keyboard user can Tab to controls hidden behind the dialog, or lose their place on the page.
const items = ["Home", "Settings", "Help"];
let index = 0;
function move(direction) {
index = (index + direction + items.length) % items.length;
return items[index];
}
console.log(move(1));
console.log(move(-1));The same wrap-around idea works for any list, so start with a simple one. Line 1 makes a list of three names. Line 2 starts at index 0, Home. On line 4, move adds the direction (1 or -1) to the index. The % sign gives the remainder after dividing by the list length. That makes the index wrap: going past the end lands on the start. Adding items.length first keeps the number from going below 0, so going before the start lands on the end.
Line 7 moves down and prints Settings. Line 8 moves back up and prints Home. A real list or menu also needs a clear style for the current item and a name for screen readers. This code only moves the index.
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 dialog is closed. After it closes, focus should be back on the opener button.
Open the dialog. The built-in <dialog> with showModal() does some of this work for you. This demo shows the trap you write yourself when you build your own dialog.
<dialog> when it fits. A dialog you build yourself also needs an inert page behind it, a name, Escape to close, and focus sent back.There are two ways to build a modal dialog. Each row shows what you get, and what you still have to do yourself.
| Which way | What you get | What you still do yourself |
|---|---|---|
dialog.showModal() | The page behind becomes inert: it can't be clicked or focused. Escape closes the dialog. | Give the dialog a name, choose where focus starts, and send focus back to the opener, because browsers differ here. |
| A trap you write | It works for a custom box when the built-in dialog doesn't fit. | Wrap Tab and Shift+Tab, handle Escape, make the page behind inert, name the dialog, and send focus back. |
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));This list holds three controls, and focus starts on open. Line 7 moves forward and prints cancel. Line 8 moves back and prints open. A real trap uses the same wrap for Tab and Shift+Tab.
Telling screen readers what changed
INTERACTIVEARIA attributes are extra attributes that describe the page to screen readers. They only describe things. They don't add any behavior.
A disclosure is a button that shows or hides a panel, like a Filters button. Give the button two attributes:
aria-expandedsays if the panel is open:"true"or"false".aria-controlsholds the panel'sid, so screen readers know which panel the button opens.
The button still needs real button behavior. ARIA doesn't add a click handler or a key handler for you.
A live region is an element that a screen reader reads aloud whenever its text changes. Focus doesn't move to it. You make one with the aria-live attribute, or with a role such as role="status".
At a stadium, an announcer reads out the score. The fans keep watching the game while they listen. Normal news waits for a quiet moment. Only an emergency message cuts in at once.
- In real life: The announcer reads out the new score
- In JavaScript: A live region reads out its new text
- In real life: The fans keep watching the game
- In JavaScript: Focus stays where it was
- In real life: Normal news waits for a quiet moment
- In JavaScript:
aria-live="polite"orrole="status" - In real life: An emergency message cuts in at once
- In JavaScript:
aria-live="assertive"orrole="alert", only for urgent messages
const button = { "aria-expanded": "false" };
button["aria-expanded"] = "true";
console.log(button["aria-expanded"]);Line 1 makes an object that stands for the button, with aria-expanded set to "false". Line 2 changes it to "true", and line 3 prints true. On a real page, your click handler must also show or hide the panel. The attribute only describes it.
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 has aria-expanded=false. The live status message was on the page from the start, and now it says: 3 results found.
role="status" waits for a quiet moment. Use role="alert" or aria-live="assertive" only for urgent messages.Put the live region on the page first, and change its text later. If the region and its text appear at the same moment, screen readers may miss the text. Use polite for normal updates, like "3 results found". Use assertive or role="alert" only for urgent messages, because they interrupt whatever the screen reader is saying.
Where you'll use keyboard and focus code
Keyboard and focus code shows up in most real websites:
- search boxes that search when you press Enter
- menus that close when you press Escape
- command palettes that open with Ctrl+K or Command+K
- skip links: a link at the top that jumps past the menu to the main content
- a list of form errors that gets focus after a failed submit
- panels that open and close, and lists that suggest words as you type
- modal dialogs
Here is the shortcut check from the step-through again, as one block of code:
const target = "page";const event = { key: "k", metaKey: true, ctrlKey: false, repeat: false, isComposing: false };const editingText = target === "input";const commandKey = event.metaKey || event.ctrlKey;const opensPalette = commandKey && event.key.toLowerCase() === "k" && !editingText && !event.repeat && !event.isComposing;console.log(opensPalette ? "open palette" : "let the browser type");Line 3 checks if focus is in a text field. Line 4 accepts Command or Ctrl. Line 5 opens the palette only for K, only outside a text field, and not for a repeat or IME typing. Focus is on the page here, so line 6 prints open palette.
Four rules to remember:
- Use
keydownfor commands. Useinputto check text. - Check where focus is before you take over a key.
- Call
preventDefault()only for the exact keys you own. - When a dialog or menu closes, send focus back to where it was.
Common mistakes
- "keypress is the event for text."
keypressis deprecated. Useinputto know when text changes. - "key and code are the same."
keyis what the key types with the current layout.codeis where the key sits. - "A div with a click handler is a button." A real
buttonalready gets focus, works with Enter and Space, can be disabled, and tells screen readers it is a button. - "focus and blur bubble." They don't. Use a capture listener, or use
focusinandfocusout, which do bubble. - "A dialog is done once it looks right." It also has to keep focus inside, make the page behind it inert, have a name, close on Escape, and send focus back.
- "A live region always works the same way." Screen readers differ, so test your live regions with real screen readers.
Practice exercises
5 EXERCISESAnswer from memory: which key event fires again and again while you hold a key down?
keydown repeats while you hold the key. On each repeat, event.repeat is true. keyup fires once, when you let go.
Read the code. What exact text does line 2 print?
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);Line 2 prints Enter, and line 3 prints false. Compare against the exact text Enter, with a capital E.
Write a function that says when to open a command palette: on Command+K or Ctrl+K, but not while the person is typing in a text field.
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 function returns true only for Command+K or Ctrl+K while the person is not typing. So line 5 prints true and line 6 prints false. Real event code would call preventDefault() only when it gets true.
A form section has a normal focus listener on the container. It never hears the inputs inside get focus. Which event, which does bubble, should it listen for instead?
Listen for focusin on the container. It bubbles, so one listener on the container hears focus arrive on any input inside. A capture listener for focus also works.
You made a custom panel. People should reach it with Tab, in the normal page order. Which tabindex value does that?
tabindex="0" puts the panel in the normal Tab order, in the order it appears on the page. tabindex="-1" would let only code focus it, which fits a dialog title better. So the answer here is 0.
Check your understanding
7 QUESTIONSQuestion 1 of 7In most browsers, what happens while you hold a key down?
Choose an answer to see the explanation.
Question 2 of 7What does this code print?
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 keyboard, the key where a US keyboard has Q gives
key: "a"andcode: "KeyQ". Which property tells you what the person meant to type?Choose an answer to see the explanation.
Question 4 of 7Which event should you listen for to know when the text in a field changes?
Choose an answer to see the explanation.
Question 5 of 7Which focus events bubble up from a child to its container?
Choose an answer to see the explanation.
Question 6 of 7Focus moves back from the first control. What does this code 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 sentence about live regions is true?
Choose an answer to see the explanation.
Key takeaways
keydownfires when a key goes down, and repeats while you hold it. Use it for commands.keyupfires once, when you let go.keyis what the key types.codeis where the key sits on the keyboard.- Use the
inputevent for text changes. It also covers pasting, IME typing, phone keyboards, and autocorrect. focusandblurdon't bubble.focusinandfocusoutdo.- A modal dialog keeps focus inside, closes on Escape, makes the page behind it inert, and sends focus back when it closes.
aria-expandedsays if a panel is open. A live region reads out new text without moving focus.
Good keyboard code sends key commands to the element that has focus, and it keeps every control reachable and every change clear to screen readers.
Up next: Page & resource loading.