EventsGENERALPLATFORM-SPECIFICBROWSER-SPECIFIC

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.

The question

What does the browser do for keyboard users on a <button> that it does not do for my <div onclick>?

The user intent

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.

The obvious build

Add a click handler to the element. Clicking works, and keyboards produce clicks too, so keyboard users are covered.

Why it breaks

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).

How it breaks in a real browser
  • 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).
  • Even with tabindex="0" added, the browser does not synthesise a click from 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 on keydown, Space activates on keyup and suppresses the page scroll in between. Reimplementations that fire on keydown for 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.keyCode still 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 keydown for 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 check isComposing submits the form mid-word.
  • Single-character shortcuts collide with screen readers, whose browse mode uses letter keys for navigation. A product-wide s shortcut can make a page unnavigable for those users.
IntentEventStateUI LogicDOM WorkNetworkLayout / PaintPixelsFeedback

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 on document still works (How an Event Is Dispatched).
  • The order for a printable key is keydownbeforeinput → the value changes → inputkeyup. keypress is deprecated and should not appear in new code.
  • event.key is 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.code is 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.repeat is true for the auto-repeat events a held key produces, so an action that should happen once per press must check it.
  • event.isComposing is true while 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 synthetic click on keydown; Space cancels its own scroll default on keydown and fires click on keyup. Assistive technology activation also produces a trusted click with 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 keydown handler 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 keydown rather 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.

accessibility specButton (native, versus rebuilt from a div)Activation, as the platform already implements it

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+TabMoves focus to and from the control. Free on a native button; requires tabindex="0" otherwise.
EnterActivates on keydown. A native button fires a synthetic trusted click.
SpaceActivates on keyup; the keydown default is cancelled so the page does not scroll. This is what makes activation abortable.
EscapeNothing on a button — but must reach an enclosing dialog, which is why blanket key handling is dangerous (preventDefault vs stopPropagation).
Focus
  • 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 disabled native button is skipped by Tab; an aria-disabled one 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).
Announces
  • Role "button" and the accessible name, from the text content, aria-label or aria-labelledby.
  • State: pressed via aria-pressed for a toggle, expanded via aria-expanded for 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.

FieldWhat it reportsLayout dependent?Use it forDo not use it for
event.keyThe value produced: a, A, Enter, ArrowUp, ?Yes — and modifier and IME dependentShortcuts, Enter/Escape/Arrow handling, anything meaning-basedPhysical position, where layout must not matter
event.codeThe physical key, named for its US-QWERTY label: KeyA, SpaceNo — the same key always reports the same codeWASD movement, key-position input, remapping UIsShortcuts a non-QWERTY user must be able to reach
event.keyCodeA legacy number conflating position and characterInconsistentlyNothing. DeprecatedAnything — except keyCode === 229, still the reliable IME-composition signal
event.repeatWhether this is an auto-repeat from a held keyNoGuarding once-per-press actionsDetecting a long press — use timing for that
event.isComposingWhether an IME is mid-compositionNoBailing out of Enter and text handling during compositionDetecting the input language
A rebuilt button, and a shortcut that does not fight the user
1// Only if a real <button> is genuinely impossible.
2el.setAttribute('role', 'button')
3el.tabIndex = 0
4
5el.addEventListener('keydown', (e: KeyboardEvent) => {
6 if (e.isComposing || e.keyCode === 229) return // IME owns this keystroke
7 if (e.key === 'Enter') { e.preventDefault(); activate() }
8 if (e.key === ' ') e.preventDefault() // stop the page scrolling
9})
10el.addEventListener('keyup', (e: KeyboardEvent) => {
11 if (e.key === ' ') activate() // Space activates on keyup
12})
13el.addEventListener('click', activate) // pointer AND assistive tech
14
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) return
18 const t = e.target as HTMLElement
19 if (t.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName)) return
20 if (!e.metaKey && !e.ctrlKey) return // never a bare letter
21 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.

Passes review, fails a keyboard
TriggerSymptomCauseResponse
<div onclick> with no tabindexUnreachable and unactivatable by keyboardNot focusable, so key events are never dispatched to itUse <button>. If impossible, role + tabindex="0" + Enter + Space (Semantics Are Behaviour).
outline: none in a resetTab moves focus invisibly; the user cannot tell where they areThe default focus indicator was removed with nothing in its placeProvide a visible :focus-visible style with real contrast (Contrast, Colour and Motion).
Space handled on keydownPage scrolls behind the control on every activationThe scroll default was not cancelled and activation is not abortableCancel the default on keydown; activate on keyup.
Bare single-letter shortcutScreen-reader users cannot navigate; typists trigger it accidentallyBrowse-mode navigation keys and text fields both use plain lettersRequire a modifier, and make shortcuts remappable and disableable (Keyboard Operability).
Enter handled without isComposingForm submits mid-word for CJK usersEnter 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 managementTab walks behind the dialog into the page underneathFocus was never moved in, and nothing constrains itMove focus in, constrain both Tab directions, restore it on close (Focus Management).
Content removed while focusedFocus jumps to the top of the page; the screen reader loses its placeFocus falls to <body> when its element disappearsMove 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 on keydown, Space on keyup, preventDefault() on the Space keydown to stop the scroll, and a visible focus indicator.
  • Branch on event.key for meaning and on event.code for physical position. Never on keyCode.
  • 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.repeat before doing anything that should happen once per press, and scope key handling to the smallest element that makes sense rather than to document.
  • 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

Failure modes
  • 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 without tabindex="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 keydown in a rebuilt button, so the page scrolls behind the control on every activation.
  • A global keydown shortcut 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.
What can arrive out of order
  • Auto-repeat delivers many keydown events before the first keyup; without a repeat check, one held key runs the action dozens of times (Reasoning About Races: A Method, Not an Instinct).
  • A keyup can be lost if focus moves or the window is blurred between press and release, so any state entered on keydown needs a recovery path.
  • Composition events interleave with key events, so a keydown handler 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).
Security
  • Any script running in the page can attach a capture-phase keydown listener on document and 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.
  • autocomplete hints 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).
Misreads
  • "Keyboards generate clicks, so a click handler is enough." Native elements generate clicks from keys. A div does not, and a div cannot 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.code is the modern keyCode." code is the physical key; key is what was produced. Using code for a shortcut hands non-QWERTY users a key in the wrong place (Internationalization).
  • "keydown is where text input happens." beforeinput and input are. keydown misses 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

How you would see this
  • 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 a tabindex (The Accessibility Tree).
  • Log key, code, repeat and isComposing together 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 keydown handler that re-renders a large list appears as a long task between the key and the frame (Long Tasks).
Slow device, slow network, large data, old tab
  • 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.code reports the US-QWERTY name for a key that produces something else entirely, so a code-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 key values for every input; beforeinput and input are the reliable signals for text changes there (Input Types, Inputmode and Autocomplete).
What this costs
  • 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, repeat and the event target on every keyboard handler is more code than a bare if (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/repeat fields and native button and link activation semantics are specified and consistent across Blink, Gecko and WebKit; the deprecated keyCode is 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.

Concurrencyui-concurrency
Domains that do not exist yet
  • 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.