How to add keyboard navigation that doesn't hijack form inputs
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:
// 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 thecontenteditableattribute, 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):
// 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:
// 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
keydownlistener will hijack form inputs unless you guard against it. - Check
e.targetfortagName === "INPUT","TEXTAREA", orisContentEditableandreturnearly —tagNameis 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.