Keyboard Events
Respond to key presses, releases, and keyboard shortcuts in React components.
Search across all documentation pages
Respond to key presses, releases, and keyboard shortcuts in React components.
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
| React Prop | TypeScript Type | Fires When | Notes |
|---|---|---|---|
onKeyDown | React.KeyboardEvent<T> | A key is pressed down | Primary keyboard event -- use this one |
onKeyUp | React.KeyboardEvent<T> | A key is released | Fires after the key action completes |
onKeyPress | React.KeyboardEvent<T> | A character key is pressed | Deprecated -- do not use in new code |
Quick-reference recipe card -- copy-paste ready.
// Enter key to submit
function EnterSubmit() {
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
e.preventDefault();
console.log("Submitted:", e.currentTarget.value);
}
};
return <input onKeyDown={handleKeyDown} placeholder="Press Enter" />;
}
// Escape key to clear / close
function EscapeToClear() {
const [value, setValue] = React.useState("");
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Escape") {
setValue("");
e.currentTarget.blur();
}
};
return (
<input
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Escape to clear"
/>
);
}
// Keyboard shortcut with modifier (Cmd/Ctrl + S)
function SaveShortcut() {
React.useEffect(() => {
const handler = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === "s") {
e.preventDefault();
console.log("Save triggered");
}
};
document.addEventListener("keydown", handler);
return () => document.removeEventListener("keydown", handler);
}, []);
return <div>Press Cmd+S (Mac) or Ctrl+S (Windows) to save</div>;
}When to reach for this: You need to respond to keyboard input -- submitting on Enter, closing on Escape, navigating with arrow keys, or registering global keyboard shortcuts.
"use client";
import { useState, useEffect, useRef, useCallback } from "react";
type SearchResult = { id: string; title: string };
const MOCK_RESULTS: SearchResult[] = [
{ id: "1", title: "Getting Started with React" },
{ id: "2", title: "React Hooks in Depth" },
{ id: "3", title: "Server Components Explained" },
{ id: "4", title: "TypeScript with React" },
{ id: "5", title: "React Performance Patterns" },
];
export default function SearchWithShortcuts() {
const [query, setQuery] = useState("");
const [isOpen, setIsOpen] = useState(false);
const [selectedIndex, setSelectedIndex] = useState(0);
const inputRef = useRef<HTMLInputElement>(null);
const results = query
? MOCK_RESULTS.filter((r) =>
r.title.toLowerCase().includes(query.toLowerCase())
)
: [];
// Global keyboard shortcut: Cmd+K to open search
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
e.preventDefault();
setIsOpen(true);
// Focus after state update renders the input
setTimeout(() => inputRef.current?.focus(), 0);
}
};
document.addEventListener("keydown", handler);
return () => document.removeEventListener("keydown", handler);
}, []);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
switch (e.key) {
case "ArrowDown":
e.preventDefault();
setSelectedIndex((i) => Math.min(i + 1, results.length - 1));
break;
case "ArrowUp":
e.preventDefault();
setSelectedIndex((i) => Math.max(i - 1, 0));
break;
case "Enter":
e.preventDefault();
if (results[selectedIndex]) {
console.log("Selected:", results[selectedIndex].title);
setIsOpen(false);
setQuery("");
}
break;
case "Escape":
setIsOpen(false);
setQuery("");
inputRef.current?.blur();
break;
}
},
[results, selectedIndex]
);
if (!isOpen) {
return (
<button
onClick={() => {
setIsOpen(true);
setTimeout(() => inputRef.current?.focus(), 0);
}}
style={{
padding: "8px 16px",
border: "1px solid #e5e7eb",
borderRadius: "8px",
background: "#fff",
cursor: "pointer",
}}
>
Search...{" "}
<kbd style={{ color: "#9ca3af", fontSize: "0.85em" }}>Cmd+K</kbd>
</button>
);
}
return (
<div
style={{
border: "1px solid #e5e7eb",
borderRadius: "12px",
overflow: "hidden",
boxShadow: "0 4px 12px rgba(0,0,0,0.1)",
width: 400,
}}
>
<input
ref={inputRef}
value={query}
onChange={(e) => {
setQuery(e.target.value);
setSelectedIndex(0);
}}
onKeyDown={handleKeyDown}
placeholder="Type to search... (Esc to close)"
style={{
width: "100%",
padding: "12px 16px",
border: "none",
outline: "none",
fontSize: "16px",
boxSizing: "border-box",
}}
/>
{results.length > 0 && (
<ul style={{ listStyle: "none", margin: 0, padding: 0 }}>
{results.map((result, index) => (
<li
key={result.id}
style={{
padding: "10px 16px",
background: index === selectedIndex ? "#f3f4f6" : "#fff",
cursor: "pointer",
}}
onClick={() => {
console.log("Selected:", result.title);
setIsOpen(false);
setQuery("");
}}
>
{result.title}
</li>
))}
</ul>
)}
{query && results.length === 0 && (
<p style={{ padding: "10px 16px", color: "#9ca3af", margin: 0 }}>
No results found.
</p>
)}
</div>
);
}What this demonstrates:
Cmd+K) using useEffect with document.addEventListeneronKeyDown handler for arrow key navigation, Enter to select, and Escape to closeselectedIndex state for keyboard-driven list navigationuseEffect return functionKeyboardEvent in a Synthetic Event. The handler receives a React.KeyboardEvent<T> with all the standard properties: key, code, altKey, ctrlKey, metaKey, shiftKey, repeat.onKeyDown fires when a key is pressed. It fires repeatedly if the key is held down (the repeat property is true on subsequent fires).onKeyUp fires once when the key is released. Use it when you need the action only after the key is fully pressed and released.onKeyPress is deprecated and removed from the DOM spec. It does not fire for non-character keys (Escape, Arrow, Ctrl, etc.). Always use onKeyDown instead.document inside a useEffect.Single key handler with e.key:
function KeyLogger() {
const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
console.log(`Key: ${e.key}, Code: ${e.code}, Repeat: ${e.repeat}`);
};
return (
<div tabIndex={0} onKeyDown={handleKeyDown}>
Focus me and press any key
</div>
);
}Modifier key combinations (Ctrl/Cmd+S to save):
function SaveHandler({ onSave }: { onSave: () => void }) {
useEffect(() => {
const handler = (e: KeyboardEvent) => {
const isMac = navigator.userAgent.includes("Mac");
const modifier = isMac ? e.metaKey : e.ctrlKey;
if (modifier && e.key === "s") {
e.preventDefault();
onSave();
}
};
document.addEventListener("keydown", handler);
return () => document.removeEventListener("keydown", handler);
}, [onSave]);
return null;
}Arrow key navigation in a list:
function ArrowNavList({ items }: { items: string[] }) {
const [activeIndex, setActiveIndex] = useState(0);
const handleKeyDown = (e: React.KeyboardEvent<HTMLUListElement>) => {
switch (e.key) {
case "ArrowDown":
e.preventDefault();
setActiveIndex((i) => (i + 1) % items.length);
break;
case "ArrowUp":
e.preventDefault();
setActiveIndex((i) => (i - 1 + items.length) % items.length);
break;
case "Home":
e.preventDefault();
setActiveIndex(0);
break;
case "End":
e.preventDefault();
setActiveIndex(items.length - 1);
break;
}
};
return (
<ul tabIndex={0} onKeyDown={handleKeyDown} role="listbox">
{items.map((item, i) => (
<li
key={item}
role="option"
aria-selected={i === activeIndex}
style={{ background: i === activeIndex ? "#e0e7ff" : "transparent" }}
>
{item}
</li>
))}
</ul>
);
}Global keyboard shortcuts with a hook:
function useKeyboardShortcut(
key: string,
callback: () => void,
options: { ctrl?: boolean; meta?: boolean; shift?: boolean } = {}
) {
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (options.ctrl && !e.ctrlKey) return;
if (options.meta && !e.metaKey) return;
if (options.shift && !e.shiftKey) return;
if (e.key.toLowerCase() !== key.toLowerCase()) return;
e.preventDefault();
callback();
};
document.addEventListener("keydown", handler);
return () => document.removeEventListener("keydown", handler);
}, [key, callback, options.ctrl, options.meta, options.shift]);
}
// Usage
function App() {
useKeyboardShortcut("k", () => openSearch(), { meta: true });
useKeyboardShortcut("/", () => openSearch());
useKeyboardShortcut("Escape", () => closeModal());
return <div>...</div>;
}Preventing default browser shortcuts:
function PreventBrowserDefault() {
useEffect(() => {
const handler = (e: KeyboardEvent) => {
// Prevent Ctrl+P (print) to open custom print dialog
if ((e.metaKey || e.ctrlKey) && e.key === "p") {
e.preventDefault();
console.log("Custom print dialog");
}
// Prevent Ctrl+F (find) to open custom search
if ((e.metaKey || e.ctrlKey) && e.key === "f") {
e.preventDefault();
console.log("Custom search");
}
};
document.addEventListener("keydown", handler);
return () => document.removeEventListener("keydown", handler);
}, []);
return <div>Browser shortcuts overridden</div>;
}// React.KeyboardEvent<T> -- T is the element type
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
e.currentTarget; // HTMLInputElement
e.key; // "Enter", "Escape", "a", "ArrowDown", etc.
e.code; // "Enter", "Escape", "KeyA", "ArrowDown", etc.
};
// e.key vs e.code
// e.key: the character produced ("a", "A", "/", "Enter")
// -- affected by keyboard layout and modifier keys
// e.code: the physical key ("KeyA", "Slash", "Enter")
// -- consistent regardless of layout
// Modifier key properties (all boolean)
// e.altKey -- Alt (Option on Mac)
// e.ctrlKey -- Control
// e.metaKey -- Cmd on Mac, Windows key on Windows
// e.shiftKey -- Shift
// e.repeat -- true if key is held down
// Native KeyboardEvent (for useEffect listeners)
useEffect(() => {
const handler = (e: KeyboardEvent) => {
// This is the native DOM KeyboardEvent, NOT React.KeyboardEvent
// Same properties, but different type
};
document.addEventListener("keydown", handler);
return () => document.removeEventListener("keydown", handler);
}, []);
// Common pattern: typing a key handler map
type KeyHandlerMap = Record<string, (e: React.KeyboardEvent) => void>;
const handlers: KeyHandlerMap = {
Enter: (e) => submit(),
Escape: (e) => close(),
ArrowDown: (e) => { e.preventDefault(); moveDown(); },
ArrowUp: (e) => { e.preventDefault(); moveUp(); },
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLElement>) => {
handlers[e.key]?.(e);
};onKeyPress is deprecated -- It does not fire for non-character keys (Escape, arrows, function keys, modifiers). It has been removed from the DOM spec. Fix: Always use onKeyDown instead. The only reason to use onKeyUp is when you specifically need the action on key release.
e.key values differ from e.code -- e.key returns "a" or "A" depending on Shift; e.code always returns "KeyA". For keyboard shortcuts, use e.key with .toLowerCase() to match regardless of case. For game controls or physical key position, use e.code.
Keyboard events require focus -- A <div> does not receive keyboard events unless it has tabIndex={0} (or -1 for programmatic focus only). Fix: Add tabIndex to non-interactive elements that need keyboard handlers, or use global document.addEventListener for shortcuts that should work regardless of focus.
Global shortcuts leak across components -- If you register Cmd+K in a useEffect but the component unmounts, the listener persists and fires on a stale closure. Fix: Always return a cleanup function from useEffect that removes the listener.
e.repeat fires continuously when a key is held -- Holding down a key fires onKeyDown repeatedly. If your handler performs an expensive action (like an API call), it will fire dozens of times. Fix: Check if (e.repeat) return; at the top of your handler if you only want the first press.
Overriding browser shortcuts is unreliable -- Some browser shortcuts (like Ctrl+T, Ctrl+W, Ctrl+N) cannot be intercepted by JavaScript because the browser handles them before your code runs. Fix: Only override shortcuts you know are interceptable (Ctrl+S, Ctrl+P, Ctrl+F, etc.) and test across browsers.
Comparing e.key with string literals is fragile for special keys -- The key values like "Enter", "Escape", "ArrowDown" are case-sensitive and follow the UI Events spec. Do not compare against "enter" or "esc". Fix: Use the exact spec values: "Enter", "Escape", "ArrowDown", "ArrowUp", "Tab", etc.
| Alternative | Use When | Don't Use When |
|---|---|---|
accessKey HTML attribute | Simple one-key activation for buttons or links | You need modifier combinations or complex logic |
document.addEventListener in useEffect | Global shortcuts that work regardless of focused element | The shortcut is scoped to a specific input or component |
| Third-party libraries (react-hotkeys-hook, tinykeys) | Many shortcuts, chord sequences, or scope management | You have one or two simple shortcuts |
| ARIA keyboard patterns | Navigating composite widgets (menus, tabs, listboxes) | Simple form inputs that handle keys natively |
contentEditable | Rich text editing with full keyboard control | Standard form inputs or navigation |
onKeyDown as your primary keyboard event -- it fires for all keys including non-character keysonKeyUp only when you need the action on key releaseonKeyPress -- it is deprecated and does not fire for Escape, arrows, or modifier keyse.key returns the character produced ("a", "A", "Enter") and is affected by keyboard layout and modifierse.code returns the physical key ("KeyA", "Enter") and is consistent regardless of layoute.key for shortcuts; use e.code for game controls or physical key positionuseEffect(() => {
const handler = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
e.preventDefault();
openSearch();
}
};
document.addEventListener("keydown", handler);
return () => document.removeEventListener("keydown", handler);
}, []);Keyboard events only fire on focused elements. A <div> is not focusable by default. Add tabIndex={0} to make it keyboard-navigable, or tabIndex={-1} for programmatic focus only. Alternatively, use document.addEventListener for global shortcuts.
onKeyDown fires repeatedly when a key is held down. The e.repeat property is true on subsequent fires. If your handler does something expensive (like an API call), add if (e.repeat) return; at the top to only handle the first press.
const handleKeyDown = (e: React.KeyboardEvent<HTMLUListElement>) => {
switch (e.key) {
case "ArrowDown":
e.preventDefault();
setIndex((i) => (i + 1) % items.length);
break;
case "ArrowUp":
e.preventDefault();
setIndex((i) => (i - 1 + items.length) % items.length);
break;
}
};
return <ul tabIndex={0} onKeyDown={handleKeyDown} role="listbox">...</ul>;If you register a document.addEventListener in useEffect but forget the cleanup function, the listener persists after unmount and fires on a stale closure. Always return a cleanup function: return () => document.removeEventListener("keydown", handler);.
No. Some shortcuts (Ctrl+T, Ctrl+W, Ctrl+N) are handled by the browser before your code runs and cannot be intercepted. Only override shortcuts you know are interceptable (Ctrl+S, Ctrl+P, Ctrl+F) and test across browsers.
const handler = (e: KeyboardEvent) => {
const isMac = navigator.userAgent.includes("Mac");
const modifier = isMac ? e.metaKey : e.ctrlKey;
if (modifier && e.key === "s") {
e.preventDefault();
save();
}
};Yes. Key values like "Enter", "Escape", "ArrowDown" follow the UI Events spec and are case-sensitive. Comparing against "enter" or "esc" will not match. Always use the exact spec values.
React.KeyboardEvent<T> is the synthetic event type used in JSX handlers (e.g., onKeyDown)KeyboardEvent (no React prefix) is the native DOM type used in useEffect with document.addEventListenertype KeyHandlerMap = Record<string, (e: React.KeyboardEvent) => void>;
const handlers: KeyHandlerMap = {
Enter: () => submit(),
Escape: () => close(),
ArrowDown: (e) => { e.preventDefault(); moveDown(); },
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLElement>) => {
handlers[e.key]?.(e);
};Reviewed by Chris St. John·Last updated Jul 16, 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥