Skip to content

Keyboard Events

Any custom interactive widget you build must support the keyboard, not just mouse/touch. This lesson covers handling keydown events and the common keyboard interaction patterns expected by users and screen readers alike.

Listening for Specific Keys

Keyboard handling in Lit uses the same @keydown=${handler} binding as any other event, reading event.key to identify which key was pressed. The modern event.key property gives a readable string ('Enter', 'Escape', 'ArrowDown') rather than the older, less readable numeric keyCode.

For any custom widget that isn't a native interactive element (a custom dropdown, a custom tab list), you're responsible for replicating the exact keyboard behavior users and assistive technology expect from that widget pattern — this is a core part of making a custom component genuinely accessible, not an optional extra.

class Dropdown extends LitElement {
  @state() private open = false;

  #onKeydown = (event) => {
    if (event.key === 'Escape' && this.open) {
      this.open = false;
      this.shadowRoot.querySelector('button')?.focus();
    }
    if (event.key === 'Enter' || event.key === ' ') {
      event.preventDefault();
      this.open = !this.open;
    }
  };

  render() {
    return html`
      <button @click=${() => (this.open = !this.open)} @keydown=${this.#onKeydown} aria-expanded=${this.open ? 'true' : 'false'}>
        Menu
      </button>
      ${this.open ? html`<ul role="menu">...</ul>` : nothing}
    `;
  }
}

Escape closes the menu and returns focus to the trigger button; Enter/Space toggle it — exactly the behavior users expect from a native-feeling dropdown.

Common Key Values

event.key === 'Enter'
event.key === ' '        // Space bar
event.key === 'Escape'
event.key === 'ArrowUp' / 'ArrowDown' / 'ArrowLeft' / 'ArrowRight'
event.key === 'Home' / 'End'
event.key === 'Tab'
  • event.key gives the actual character or named key, already accounting for Shift/keyboard layout differences.
  • Use event.code instead only when you specifically need the physical key position, independent of layout (rare for most UI needs).
  • Remember to call event.preventDefault() for keys like Space (which otherwise scrolls the page) inside custom interactive elements.
  • event.shiftKey, event.ctrlKey, event.metaKey, and event.altKey report the state of modifier keys alongside the main key.

Standard Widget Keyboard Patterns

Expected keyboard behavior for common custom widget types.

Widget Expected Keys
Button (custom, non-native) Enter and Space both activate
Dropdown/menu Enter/Space open, Escape closes, Arrow keys navigate items
Tabs Arrow keys move between tabs, Home/End jump to first/last
Modal dialog Escape closes, Tab is trapped within the dialog while open
Combobox/autocomplete Arrow keys navigate suggestions, Enter selects, Escape closes

Roving tabindex for Composite Widgets

For a composite widget with multiple focusable items (like a tab list or a custom listbox), the standard accessible pattern is 'roving tabindex': only the currently active item has tabindex="0", and every other item has tabindex="-1", so pressing Tab moves focus in and out of the whole widget as one stop, while arrow keys move focus between items inside it.

render() {
  return html`
    <div role="tablist" @keydown=${this.#onKeydown}>
      ${this.tabs.map((tab, i) => html`
        <button
          role="tab"
          tabindex=${i === this.activeIndex ? '0' : '-1'}
          aria-selected=${i === this.activeIndex ? 'true' : 'false'}
        >${tab.label}</button>
      `)}
    </div>
  `;
}

#onKeydown(event) {
  if (event.key === 'ArrowRight') this.activeIndex = (this.activeIndex + 1) % this.tabs.length;
  if (event.key === 'ArrowLeft') this.activeIndex = (this.activeIndex - 1 + this.tabs.length) % this.tabs.length;
  this.shadowRoot.querySelectorAll('[role="tab"]')[this.activeIndex]?.focus();
}

Only one tab is ever reachable via a plain Tab key press at a time; arrow keys handle movement within the group itself.

Trapping Focus Inside an Open Dialog

When a modal dialog is open, Tab/Shift+Tab should cycle only through focusable elements inside the dialog, not escape to the rest of the page. The native <dialog> element with .showModal() handles this automatically and is strongly preferred over hand-rolled focus trapping whenever it fits your design.

Common Mistakes

  • Building a custom interactive widget that only responds to mouse clicks, with no keyboard support at all.
  • Checking the deprecated event.keyCode instead of the modern, more readable event.key.
  • Forgetting event.preventDefault() for Space on custom buttons, causing an unwanted page scroll.
  • Implementing a composite widget without roving tabindex, forcing keyboard users to Tab through every single item individually.

Key Takeaways

  • Keyboard handling uses the same @keydown binding, checking event.key for the specific key pressed.
  • Custom, non-native interactive widgets must replicate the exact keyboard behavior users expect for that widget pattern.
  • Roving tabindex is the standard pattern for composite widgets with multiple internally navigable items.
  • Prefer the native <dialog> element's built-in focus trapping over hand-rolled solutions whenever possible.

Pro Tip

Before building a custom interactive widget from scratch, check the WAI-ARIA Authoring Practices Guide for that exact widget pattern (tabs, listbox, combobox, dialog) — it documents the precise expected keyboard behavior, so you're implementing an established standard rather than guessing.