Keyboard Events
Why Enter and Space activate a native button on different events, why a clickable div gets neither, and why key and code answer different questions.
The intent, the obvious build, and why it breaks
Every lesson starts where the work starts: someone wanted an outcome, and the first implementation that comes to mind has a problem.
What does the browser do for keyboard users on a <button> that it does not do for my <div onclick>?
Someone is operating the product without a mouse — by choice, by injury, by screen reader, by switch, or because their hands are busy. They expect to reach every control and activate it.
Add a click handler to the element. Clicking works, and keyboards produce clicks too, so keyboard users are covered.
A <div> is not focusable. Key events are dispatched at document.activeElement, so a div that cannot hold focus never receives a key event of any kind — there is nothing to handle (How an Event Is Dispatched).
- A
<div>is not focusable. Key events are dispatched atdocument.activeElement, so a div that cannot hold focus never receives a key event of any kind — there is nothing to handle (How an Event Is Dispatched). - Even with
tabindex="0"added, the browser does not synthesise aclickfrom Enter or Space on a generic element. That synthesis is native-element behaviour, not a keyboard feature (What Native Elements Already Do). - A native
<button>treats the two keys differently: Enter activates onkeydown, Space activates onkeyupand suppresses the page scroll in between. Reimplementations that fire onkeydownfor both feel subtly wrong and cannot be aborted by moving off the key. - An
<a href>is not a button: Enter follows the link, Space scrolls the page. Using an anchor as a button gives keyboard users half a control. event.keyCodestill appears in most tutorials and is deprecated. It conflates the physical key with the produced character, and its numeric values were never fully consistent between engines.- Handling
keydownfor a printable key breaks input method editors: while composing Japanese, Chinese or Korean text, Enter confirms a candidate rather than submitting — and code that does not checkisComposingsubmits the form mid-word. - Single-character shortcuts collide with screen readers, whose browse mode uses letter keys for navigation. A product-wide
sshortcut can make a page unnavigable for those users.
What is actually happening
In the browser, not in the framework.
- Key events go to the focused element and then propagate along the usual path. With nothing focused, they are dispatched at
<body>, which is why a global shortcut listener ondocumentstill works (How an Event Is Dispatched). - The order for a printable key is
keydown→beforeinput→ the value changes →input→keyup.keypressis deprecated and should not appear in new code. event.keyis the produced value:a,A,Enter,ArrowUp,Escape,?. It depends on layout, on modifiers and on the IME state. Use it for anything meaning-based — shortcuts, confirmation keys, text entry.event.codeis the physical key position, named after its US-QWERTY label:KeyA,Space,Digit1,Enter. It does not change with layout, which is exactly why it is right for WASD movement and wrong for a shortcut a French or Cyrillic user has to type (Internationalization).event.repeatistruefor the auto-repeat events a held key produces, so an action that should happen once per press must check it.event.isComposingistruewhile an IME is composing. Enter and other keys have a different meaning during composition, and the composition events (compositionstart,compositionupdate,compositionend) delimit it.- A native
<button>implements activation itself: Enter fires a syntheticclickon keydown; Space cancels its own scroll default on keydown and firesclickon keyup. Assistive technology activation also produces a trustedclickwith no key events at all.
What this makes the browser do
And which of it is avoidable.
- Dispatching up to three or four events per keystroke along the full path, plus the default action — inserting a character, moving the caret, scrolling, moving focus.
- Recomputing text layout on every insertion in a text field, which is why a
keydownhandler that also re-renders a large list makes typing lag behind the caret (List Virtualization). - Maintaining focus order and the sequential navigation the Tab key walks — derived from DOM order and
tabindex, and recomputed as the DOM changes (Focus Management). - The avoidable work is in the handler: filtering or re-rendering on every
keydownrather than debouncing, and reading layout during typing.
What `<button>` was doing for you
The specification below is not a list of nice-to-haves. It is an itemisation of behaviour a native <button> already implements, and therefore an itemisation of the debt taken on by every rebuild. Read it as a bill.
The Space-on-keyup detail is the one people are most surprised by, and it is not arbitrary: it makes activation abortable. Press and hold Space, realise it is the wrong control, move focus or release outside — the native element gives the user that escape, and a keydown-based reimplementation does not.
semantics <button type="button">. Rebuilt: role="button" plus tabindex="0" plus an accessible name — all three, or the control is worse than the untagged version.
| Tab / Shift+Tab | Moves focus to and from the control. Free on a native button; requires tabindex="0" otherwise. |
| Enter | Activates on keydown. A native button fires a synthetic trusted click. |
| Space | Activates on keyup; the keydown default is cancelled so the page does not scroll. This is what makes activation abortable. |
| Escape | Nothing on a button — but must reach an enclosing dialog, which is why blanket key handling is dangerous (preventDefault vs stopPropagation). |
- — Focusable by default and in DOM order; a rebuilt control needs
tabindex="0", never a positive value. - — A visible focus indicator, which is the default outline unless you removed it — in which case you owe a replacement with sufficient contrast (Contrast, Colour and Motion).
- — A
disablednative button is skipped by Tab; anaria-disabledone stays reachable so it can explain itself, which is often the better choice. - — If activation removes the button from the DOM, move focus deliberately — otherwise it falls to
<body>and the screen reader loses its place (Focus Management).
- — Role "button" and the accessible name, from the text content,
aria-labeloraria-labelledby. - — State: pressed via
aria-pressedfor a toggle, expanded viaaria-expandedfor a disclosure. - — The result of the action — through a live region, a focus move, or a change to the button's own name (Live Regions and Announcement).
usually broken by The rebuilt version that stops at role="button" and a click handler. It announces as a button, is not reachable by Tab, does not respond to Enter or Space, and looks completely correct in a browser driven by a mouse (Div Soup: How It Happens and What It Costs).
`key`, `code`, and the field that should not appear in new code
These three fields answer different questions, and the choice between the first two is a real design decision rather than a style preference. key asks "what did the user produce"; code asks "which physical key did they press". A shortcut is about meaning, so it wants key. Game movement is about hand position, so it wants code.
The layout case makes the distinction concrete. On AZERTY, the key labelled KeyQ in the US layout produces a. A shortcut branching on code === 'KeyQ' fires when a French user types the letter A; one branching on key === 'q' fires when they press the key that produces q, wherever that key physically is. The second is what the user meant.
| Field | What it reports | Layout dependent? | Use it for | Do not use it for |
|---|---|---|---|---|
event.key | The value produced: a, A, Enter, ArrowUp, ? | Yes — and modifier and IME dependent | Shortcuts, Enter/Escape/Arrow handling, anything meaning-based | Physical position, where layout must not matter |
event.code | The physical key, named for its US-QWERTY label: KeyA, Space | No — the same key always reports the same code | WASD movement, key-position input, remapping UIs | Shortcuts a non-QWERTY user must be able to reach |
event.keyCode | A legacy number conflating position and character | Inconsistently | Nothing. Deprecated | Anything — except keyCode === 229, still the reliable IME-composition signal |
event.repeat | Whether this is an auto-repeat from a held key | No | Guarding once-per-press actions | Detecting a long press — use timing for that |
event.isComposing | Whether an IME is mid-composition | No | Bailing out of Enter and text handling during composition | Detecting the input language |
1// Only if a real <button> is genuinely impossible.2el.setAttribute('role', 'button')3el.tabIndex = 04 5el.addEventListener('keydown', (e: KeyboardEvent) => {6 if (e.isComposing || e.keyCode === 229) return // IME owns this keystroke7 if (e.key === 'Enter') { e.preventDefault(); activate() }8 if (e.key === ' ') e.preventDefault() // stop the page scrolling9})10el.addEventListener('keyup', (e: KeyboardEvent) => {11 if (e.key === ' ') activate() // Space activates on keyup12})13el.addEventListener('click', activate) // pointer AND assistive tech14 15// A global shortcut. Every guard here corresponds to a user it would break.16document.addEventListener('keydown', (e: KeyboardEvent) => {17 if (e.isComposing || e.repeat) return18 const t = e.target as HTMLElement19 if (t.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName)) return20 if (!e.metaKey && !e.ctrlKey) return // never a bare letter21 if (e.key.toLowerCase() === 'k') { e.preventDefault(); openPalette() }22})Three listeners for one button, and click is still required — assistive technology activation produces a trusted click with no key events at all.
The keyboard bugs nobody sees with a mouse
Each row below is a defect that passes a manual test, passes most automated checks, and reaches production regularly. The unifying property is that the developer, the reviewer and the tooling all interacted with the page using a pointing device.
| Trigger | Symptom | Cause | Response |
|---|---|---|---|
<div onclick> with no tabindex | Unreachable and unactivatable by keyboard | Not focusable, so key events are never dispatched to it | Use <button>. If impossible, role + tabindex="0" + Enter + Space (Semantics Are Behaviour). |
outline: none in a reset | Tab moves focus invisibly; the user cannot tell where they are | The default focus indicator was removed with nothing in its place | Provide a visible :focus-visible style with real contrast (Contrast, Colour and Motion). |
Space handled on keydown | Page scrolls behind the control on every activation | The scroll default was not cancelled and activation is not abortable | Cancel the default on keydown; activate on keyup. |
| Bare single-letter shortcut | Screen-reader users cannot navigate; typists trigger it accidentally | Browse-mode navigation keys and text fields both use plain letters | Require a modifier, and make shortcuts remappable and disableable (Keyboard Operability). |
Enter handled without isComposing | Form submits mid-word for CJK users | Enter confirms an IME candidate before it ever means "submit" | Bail on isComposing or keyCode === 229 (Submission: Method, Encoding and Doing It Once). |
| Modal without focus management | Tab walks behind the dialog into the page underneath | Focus was never moved in, and nothing constrains it | Move focus in, constrain both Tab directions, restore it on close (Focus Management). |
| Content removed while focused | Focus jumps to the top of the page; the screen reader loses its place | Focus falls to <body> when its element disappears | Move focus to a sensible neighbour or a status message before removal. |
How to build it
Most important first.
- Use the native element.
<button>for actions,<a href>for navigation,<input>for values. Focusability, activation keys, the role, the accessible name and the disabled behaviour all arrive together and are already correct (Semantics Are Behaviour). - If you truly cannot — and this is rarer than it feels — then a rebuilt button needs
role="button",tabindex="0", Enter onkeydown, Space onkeyup,preventDefault()on the Spacekeydownto stop the scroll, and a visible focus indicator. - Branch on
event.keyfor meaning and onevent.codefor physical position. Never onkeyCode. - Guard every text-related keydown handler with
if (e.isComposing || e.keyCode === 229) return. The legacy code check is the reliable cross-engine signal for "the IME is handling this". - Check
event.repeatbefore doing anything that should happen once per press, and scope key handling to the smallest element that makes sense rather than todocument. - Avoid single-character shortcuts, or make them remappable and disableable, and require a modifier by default — this is a published accessibility requirement, not a preference (Keyboard Operability).
- Leave Tab alone everywhere except inside a modal dialog, and there implement both directions correctly (Accessible Component Patterns).
Keyboard, focus, semantics, announcement
A required field on every lesson in this domain, not a section added when there is room.
- This lesson is an accessibility lesson wearing an events title. The keyboard is the substrate that screen readers, switch devices and voice control all build on: if a control is not keyboard-operable, none of them can reach it (Keyboard Operability).
- Everything interactive must be reachable by Tab, activatable by Enter or Space as appropriate to its role, and dismissable by Escape where it is dismissable at all.
- Focus must be visible. Removing the default outline without providing a replacement is the single most common self-inflicted accessibility bug on the web (Focus Management).
- The keyboard test costs nothing: put the mouse down and Tab through the flow. If you cannot complete it, neither can a meaningful share of your users (Accessibility Testing).
- Announcement is separate from operability. A control that responds to Enter but never changes its accessible name or state leaves a screen-reader user with no confirmation that anything happened (Live Regions and Announcement).
What can go wrong
- A
<div onclick>shipped and reviewed and tested by hand with a mouse. It is invisible to keyboard users, invisible to screen-reader users, and invisible to every automated check that only looks at the rendered pixels (Div Soup: How It Happens and What It Costs). role="button"added withouttabindex="0". Now assistive technology announces a button that no keyboard user can reach — arguably worse than the untagged version, because it promises something that is not there (The Rules of ARIA).- Space handled on
keydownin a rebuilt button, so the page scrolls behind the control on every activation. - A global
keydownshortcut with no target check, firing while the user types the same letter into a search field. - Enter submitting a form during IME composition, so the user's half-composed text is sent and the input clears mid-word.
- The mitigation failing: a focus trap that handles Tab but not Shift+Tab, leaving keyboard users able to enter a dialog and unable to move backwards inside it.
- Auto-repeat delivers many
keydownevents before the firstkeyup; without arepeatcheck, one held key runs the action dozens of times (Reasoning About Races: A Method, Not an Instinct). - A
keyupcan be lost if focus moves or the window is blurred between press and release, so any state entered onkeydownneeds a recovery path. - Composition events interleave with key events, so a
keydownhandler can observe an Enter that the IME is about to consume. - A handler that awaits before acting resumes after the default action has already run and possibly after focus has moved elsewhere (The Microtask Checkpoint).
- Any script running in the page can attach a capture-phase
keydownlistener ondocumentand read every keystroke, including into password fields. Same-origin script is fully trusted by the platform (Third-Party Scripts and the Supply Chain). - This is a direct consequence of the XSS threat model: script injection is keystroke capture, which is why input sanitisation and CSP matter more than any field-level measure (Cross-Site Scripting).
- The browser will not let a page read keys typed into a cross-origin iframe. Payment and auth widgets are framed for exactly that reason (The Same-Origin Policy).
- Keyboard activation grants transient user activation just as a click does, so gated APIs work identically for keyboard users — a fact worth verifying, since activation-gated features are usually only tested with a mouse.
autocompletehints and password-manager integration depend on native inputs with correct types and names; a rebuilt keyboard-handled field opts out of both (Input Types, Inputmode and Autocomplete).
- "Keyboards generate clicks, so a click handler is enough." Native elements generate clicks from keys. A
divdoes not, and adivcannot even be focused to try (Div Soup: How It Happens and What It Costs). - "
tabindex="0"makes it accessible." It makes it focusable. Role, name, activation keys and state are all still missing (Semantics Before ARIA). - "Enter and Space are interchangeable." On a button, Enter fires on keydown and Space on keyup. On a link, Space scrolls the page and does not activate anything.
- "
event.codeis the modernkeyCode."codeis the physical key;keyis what was produced. Usingcodefor a shortcut hands non-QWERTY users a key in the wrong place (Internationalization). - "
keydownis where text input happens."beforeinputandinputare.keydownmisses IME composition, paste, drag-and-drop and autofill entirely. - "An automated accessibility check passed, so keyboard access works." Tools verify names and roles. Whether a person can complete the flow with a keyboard is not a property a tool can evaluate (Accessibility Testing).
Measuring it, and what changes in the field
- Tab through the page and watch the focus indicator. It finds more real defects per minute than any tool, and needs nothing installed (Accessibility Testing).
- Chromium's Accessibility pane shows the computed role, name and focusability of the selected node — the fastest way to see that
role="button"never got atabindex(The Accessibility Tree). - Log
key,code,repeatandisComposingtogether while debugging. Most shortcut bugs are one of those four fields being ignored. - Automated checks catch missing names and roles but cannot judge whether a keyboard flow is usable. Treat a clean report as the start of the work, not the end (Accessibility Testing).
- The Performance panel shows typing latency directly: a
keydownhandler that re-renders a large list appears as a long task between the key and the frame (Long Tasks).
- On a slow device, per-keystroke work is felt immediately, because the caret is the most latency-sensitive thing on the page (Interaction Responsiveness).
- On a non-US layout,
event.codereports the US-QWERTY name for a key that produces something else entirely, so acode-based shortcut can be physically awkward or impossible to reach (Internationalization). - With an IME active, one visible character can span many keystrokes, and Enter means "confirm the candidate" long before it means "submit".
- With a screen reader in browse mode, single-letter keys are navigation commands and may never reach your handler at all (Semantics Before ARIA).
- On mobile, virtual keyboards may not emit meaningful
keyvalues for every input;beforeinputandinputare the reliable signals for text changes there (Input Types, Inputmode and Autocomplete).
- Native elements constrain styling. That constraint is real, and it is still the right default: the number of teams who have successfully rebuilt a button's full keyboard, focus and assistive-technology behaviour is smaller than the number who believe they have.
- Checking
isComposing,repeatand the event target on every keyboard handler is more code than a bareif (e.key === 'Enter'), and each guard exists because of a real class of user it otherwise breaks. - Keyboard shortcuts are genuinely valuable for power users and genuinely hazardous for assistive-technology users. Requiring a modifier and offering remapping keeps both, and costs you a settings surface.
Where this applies
Frontend advice ages badly and fragments across engines. These labels say what each claim is specific to, and where a different browser, device or framework would differ.
- GENERALThe
key/code/isComposing/repeatfields and native button and link activation semantics are specified and consistent across Blink, Gecko and WebKit; the deprecatedkeyCodeis the field whose values were never fully aligned between them. - PLATFORM-SPECIFICWhich key means "activate" and which modifier owns shortcuts is an OS convention: macOS uses Command where Windows and Linux use Control, and macOS additionally requires full keyboard access to be enabled before Tab reaches every control in Safari — so an identical page has a different tab order by default per platform.
- BROWSER-SPECIFICScreen-reader interception varies by pairing rather than by browser alone: NVDA and JAWS in browse mode consume single-letter keys before the page sees them, while VoiceOver passes more through, so a shortcut that works in one combination silently never fires in another.
Where the depth lives
This domain teaches the browser-side mechanism and hands the rest off.
- — Testing & Reliability Engineering — keyboard operability is the clearest example of a requirement that automation can partially check and never certify; the acceptance test is a person completing the flow without a pointing device.