ehsan.blog
~/blog/how-to-add-keyboard-navigation-without-hijacking-forms — zsh
cat how-to-add-keyboard-navigation-without-hijacking-forms.md

How to add keyboard navigation that doesn't hijack form inputs

·4 min read

Keyboard navigation makes a site feel polished and, more importantly, usable without a mouse. On my portfolio you can press the arrow keys (or Page Up/Down, Home, End) to move between full-screen sections of the horizontal-scroll slideshow — the same panels you can also reach by wheel scrolling or swiping. But there’s a trap that’s easy to walk into: if you naively listen for arrow keys on the whole window, you’ll break every text field on the page. This post shows the handler and the one guard that makes it safe.

The problem with global key listeners

To catch keys anywhere on the page, you attach a keydown listener to window. That’s fine until the visitor clicks into a text input and starts typing. Now pressing the Left arrow to move the text cursor also fires your handler and jumps to the previous section. Pressing Home to jump to the start of a line teleports them to the first slide. The navigation has “hijacked” the input.

The fix is to detect when the keystroke originated inside an editable element and simply do nothing.

Bail out when focus is in a field

Every DOM event carries a target — the element the event came from. We check it before doing anything else:

tsx
// src/App.tsx
const onKey = (e: KeyboardEvent) => {
  const el = e.target as HTMLElement | null
  if (
    el &&
    (el.tagName === "INPUT" ||
      el.tagName === "TEXTAREA" ||
      el.isContentEditable)
  ) {
    return // typing in a field — let the keystroke through untouched
  }
  // ...navigation logic below
}

Three cases cover essentially all editable UI:

  • el.tagName === "INPUT" — text boxes, search fields, etc.
  • el.tagName === "TEXTAREA" — multi-line inputs.
  • el.isContentEditable — any element with the contenteditable attribute, like rich-text editors.

tagName is always uppercase in HTML, so we compare against "INPUT", not "input" — a classic gotcha. If any of these match, we return early and the keystroke behaves completely normally inside the field.

The navigation logic

Past the guard, we map keys onto our goTo() function (which slides the slideshow to a given index):

tsx
// src/App.tsx
if (["ArrowRight", "ArrowDown", "PageDown"].includes(e.key)) {
  e.preventDefault()
  setCurrent((c) => {
    goTo(c + 1)
    return c
  })
} else if (["ArrowLeft", "ArrowUp", "PageUp"].includes(e.key)) {
  e.preventDefault()
  setCurrent((c) => {
    goTo(c - 1)
    return c
  })
} else if (e.key === "Home") {
  e.preventDefault()
  goTo(0)
} else if (e.key === "End") {
  e.preventDefault()
  goTo(totalSections - 1)
}

Right / Down / Page Down all move forward; Left / Up / Page Up move back; Home jumps to the first section and End to the last. Grouping several keys with ["ArrowRight", "ArrowDown", "PageDown"].includes(e.key) keeps it readable and matches what people expect from a slideshow.

e.preventDefault() stops the browser’s built-in behavior for those keys — without it, Page Down would also try to scroll the document, fighting our animation.

Reading current without stale closures

Notice the slightly odd setCurrent((c) => { goTo(c + 1); return c }). We’re not actually changing current here — we return c unchanged. We’re using the functional updater purely to read the latest value of current synchronously.

If we instead wrote goTo(current + 1) directly, current would be captured from the closure when the effect ran and could be stale. Reading it inside the updater guarantees we act on the true current index. goTo itself is the thing that updates state.

Wiring it up

The whole thing lives in an effect that adds and cleans up the listener:

tsx
// src/App.tsx
useEffect(() => {
  if (isMobile) return // desktop-only interaction
  const onKey = (e: KeyboardEvent) => { /* ...as above... */ }
  window.addEventListener("keydown", onKey)
  return () => window.removeEventListener("keydown", onKey)
}, [isMobile, goTo, totalSections])

Returning the removeEventListener cleanup is essential — without it you’d stack up duplicate listeners every time the effect re-runs. The if (isMobile) return skips keyboard nav on touch devices, where swiping is the natural gesture.

Takeaways

  • A global keydown listener will hijack form inputs unless you guard against it.
  • Check e.target for tagName === "INPUT", "TEXTAREA", or isContentEditable and return early — tagName is uppercase.
  • Map arrow / Page / Home / End keys to your navigation and call e.preventDefault() so the browser doesn’t also scroll.
  • Read the current index inside a functional state updater to avoid a stale closure, and always clean up the listener.
ls ./related
cat ./comments

Comments are not configured yet. Enable GitHub Discussions and paste the giscus repo-id / category-id into src/consts.ts.