with one click
frontend-hotkeys
// Use when creating or modifying keyboard shortcuts/hotkeys in frontend code
// Use when creating or modifying keyboard shortcuts/hotkeys in frontend code
[HINT] Download the complete skill directory including SKILL.md and all related files
| name | frontend-hotkeys |
| description | Use when creating or modifying keyboard shortcuts/hotkeys in frontend code |
Use react-hotkeys-hook for all keyboard shortcuts.
Naming convention: See frontend-naming-conventions for the useHotkeys_[ComponentName] pattern.
pnpm add react-hotkeys-hook
import { useHotkeys } from "react-hotkeys-hook";
export const useHotkeys_PageScene = () => {
const pPageScene = useProvider_Page_Scene();
// Simple hotkey (blocked in inputs by default)
useHotkeys(
"space",
() => {
pPageScene.setState((prev) => ({
timeline_isPlaying: !prev.timeline_isPlaying,
}));
},
{ preventDefault: true }
);
// Escape - ALWAYS use enableOnFormTags: true
useHotkeys(
"escape",
() => {
pPageScene.setState({ nodeSelected: null });
},
{ enableOnFormTags: true }
);
// Conditional hotkey (check inside callback)
useHotkeys(
"f",
() => {
if (pPageScene.state.nodeSelected?.category !== "shot") return;
pPageScene.togglePreviewFullscreenRef.current?.();
},
{ preventDefault: true }
);
};
| Option | Default | Description |
|---|---|---|
preventDefault | false | Prevent browser default (use for Space, F, etc.) |
enableOnFormTags | false | Allow in input/textarea/select |
enableOnContentEditable | false | Allow in contentEditable elements |
enabled | true | Conditionally enable/disable the hotkey |
keyup | false | Trigger on keyup instead of keydown |
keydown | true | Trigger on keydown |
Hotkeys are blocked in input/textarea/select by default. This is the desired behavior.
// This will NOT fire when user is typing in an input
useHotkeys("space", () => togglePlayback());
Escape should always work, even in inputs. Always add enableOnFormTags: true:
// ā
Escape works everywhere
useHotkeys("escape", () => cancelAction(), { enableOnFormTags: true });
For keys with browser defaults (Space scrolls, F can trigger form), use preventDefault:
useHotkeys('space', () => { ... }, { preventDefault: true });
useHotkeys('f', () => { ... }, { preventDefault: true });
RULE: If you read external values, you MUST use useHotkeysWithDeps to declare deps.
import { useHotkeysWithDeps } from "@/hooks/hotkeys";
// ā
Reading external values - MUST use useHotkeysWithDeps
useHotkeysWithDeps(
() => {
if (nodeSelected?.category === "shot") {
deleteKeyframes(nodeSelected.selectedKeyframeIds);
}
},
[nodeSelected, deleteKeyframes], // ESLint enforces this
"delete"
);
RULE: If you have NO external dependencies, use native useHotkeys.
import { useHotkeys } from "react-hotkeys-hook";
// ā
No external deps - use native useHotkeys with functional update
useHotkeys(
"space",
() => {
pPageScene.setState((prev) => ({
timeline_isPlaying: !prev.timeline_isPlaying,
}));
},
{ preventDefault: true }
);
Signature comparison:
// Native (no deps):
useHotkeys(keys, callback, options);
// With deps (ESLint enforced):
useHotkeysWithDeps(callback, deps, keys, options);
| Scenario | Hook to Use |
|---|---|
| Reading external state/values | useHotkeysWithDeps (MUST declare deps) |
Functional update only (prev => ...) | useHotkeys (no deps needed) |
| Calling functions with external IDs | useHotkeysWithDeps (MUST declare deps) |
Two patterns for conditional hotkeys:
// Pattern A: Check inside callback (simpler)
useHotkeys("f", () => {
if (nodeSelected?.category !== "shot") return;
toggleFullscreen();
});
// Pattern B: Use enabled option (prevents callback entirely)
useHotkeys(
"f",
() => toggleFullscreen(),
{
enabled: nodeSelected?.category === "shot",
},
[nodeSelected]
);
Use Pattern A for simple checks. Use Pattern B when the check itself is expensive.
Colocate hotkey hooks with their component:
Page_Scene/
āāā Page_Scene.tsx # calls useHotkeys_PageScene()
āāā useHotkeys_PageScene.ts # Space, Escape, Delete, F
āāā PageScene_Timeline/
āāā PageScene_Timeline.tsx # calls useHotkeys_PageScene_Timeline()
āāā useHotkeys_PageScene_Timeline.ts # Delete for keyframes
// ā Wrong - manual listener (old pattern)
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.code === 'Space') { ... }
};
document.addEventListener('keydown', handler);
return () => document.removeEventListener('keydown', handler);
}, []);
// ā
Correct - use library
useHotkeys('space', () => { ... }, { preventDefault: true });
// ā Wrong - forgetting enableOnFormTags for Escape
useHotkeys("escape", () => cancel());
// ā
Correct - Escape works in inputs
useHotkeys("escape", () => cancel(), { enableOnFormTags: true });
// ā Wrong - reading external value without declaring deps
const [count, setCount] = useState(0);
useHotkeys("space", () => console.log(count)); // count is stale!
// ā
Correct - reading external value, MUST use useHotkeysWithDeps
useHotkeysWithDeps(() => console.log(count), [count], "space");
// ā
Correct - no external deps, use native useHotkeys
useHotkeys("space", () => setCount((prev) => prev + 1));
Do NOT use react-hotkeys-hook for:
useFrame)These need manual keydown/keyup listeners to track held keys:
// This is CORRECT for continuous input (FlyControls, etc.)
const keys = useRef({ w: false, a: false, s: false, d: false });
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
const key = e.key.toLowerCase();
if (key in keys.current) keys.current[key] = true;
};
const handleKeyUp = (e: KeyboardEvent) => {
const key = e.key.toLowerCase();
if (key in keys.current) keys.current[key] = false;
};
document.addEventListener("keydown", handleKeyDown);
document.addEventListener("keyup", handleKeyUp);
return () => {
document.removeEventListener("keydown", handleKeyDown);
document.removeEventListener("keyup", handleKeyUp);
};
}, []);
useFrame(() => {
if (keys.current.w) camera.position.add(forward);
// ...
});
// Modifier + key
useHotkeys("ctrl+s", () => save(), { preventDefault: true });
useHotkeys("cmd+s", () => save(), { preventDefault: true }); // macOS
// Multiple keys (comma-separated)
useHotkeys("ctrl+s, cmd+s", () => save(), { preventDefault: true });
// Array syntax
useHotkeys(["ctrl+z", "cmd+z"], () => undo());
Not used initially. Add if needed for complex modal/state management:
import { HotkeysProvider, useHotkeysContext } from 'react-hotkeys-hook';
// Wrap app
<HotkeysProvider initiallyActiveScopes={['global']}>
<App />
</HotkeysProvider>
// Define scoped hotkey
useHotkeys('space', () => play(), { scopes: 'timeline' });
// Enable/disable scopes
const { enableScope, disableScope } = useHotkeysContext();
disableScope('timeline'); // Space no longer triggers