useSupported
Installation
npx sse-hooks add use-supported
yarn dlx sse-hooks add use-supported
pnpm dlx sse-hooks add use-supported
deno run -A npm:sse-hooks add use-supported
bunx sse-hooks add use-supported
Usage
API
Parameters
| Parameter | Default | Type |
|---|---|---|
options |
|
|
keyMap | - |
|
Returns
| Return Value | Default | Type |
|---|---|---|
bindings | - |
|
Types Aliases
KeyMap
typetype KeyMap = Record<string, KeyHandler | KeyConfig>;
KeyHandler
typetype KeyHandler = (event: React.KeyboardEvent) => void;
KeyConfig
interface| Property | Type |
|---|---|
action |
The function to call |
description? |
Description for UI (e.g. "Save File") |
category? |
Category for UI (e.g. "Navigation") |
preventDefault? |
Prevent default browser behavior |
stopPropagation? |
Stop event propagation |
once? |
Only fire this hotkey once, then disable it |
allowInInputs? |
Allow this hotkey inside inputs/textareas |
Code
import { useEffect, useRef, useState, KeyboardEvent, useMemo } from "react";
import React from "react";
import { useIsomorphicLayoutEffect } from "./use-isomorphic-layout-effect";
export type KeyHandler = (event: React.KeyboardEvent) => void;
export type KeyConfig = {
/** The function to call */
action: KeyHandler;
/** Description for UI (e.g. "Save File") */
description?: string;
/** Category for UI (e.g. "Navigation") */
category?: string;
/** Prevent default browser behavior */
preventDefault?: boolean;
/** Stop event propagation */
stopPropagation?: boolean;
/** Only fire this hotkey once, then disable it */
once?: boolean;
/** Allow this hotkey inside inputs/textareas */
allowInInputs?: boolean;
};
export type KeyMap = Record<string, KeyHandler | KeyConfig>;
export type UseKeyOptions = {
/** DOM target to listen on (default: window) */
target?: React.RefObject<HTMLElement> | null;
/** Event type (default: keydown) */
event?: "keydown" | "keyup";
/** Global toggle to disable all hooks */
enabled?: boolean;
/** If true, logs key presses to console */
debug?: boolean;
/** Time in ms to wait for a sequence (e.g. "g g") */
sequenceTimeout?: number;
/** Custom filter: return false to skip the event */
filter?: (e: React.KeyboardEvent) => boolean;
};
export const KEY_ALIASES: Record<string, string> = {
ctrl: "control",
opt: "alt",
option: "alt",
cmd: "meta",
command: "meta",
esc: "escape",
space: " ",
" ": "space",
};
/**
* Normalizes a key event or string into a standard format:
* "ctrl+alt+shift+key" (modifiers sorted alphabetically).
*/
export function normalizeKeyCombo(e: React.KeyboardEvent | string): string {
if (typeof e === "string") {
// Handle sequences like "g g" -> recursive normalization is tricky,
// so we assume the user config passes individual chords like "ctrl+k"
const parts = e
.toLowerCase()
.split("+")
.map((p) => p.trim());
const key = parts.pop();
const mods = parts.map((m) => KEY_ALIASES[m] || m).sort();
return [...mods, key === " " ? "space" : key].join("+");
}
const mods = [];
if (e.ctrlKey) mods.push("control");
if (e.altKey) mods.push("alt");
if (e.metaKey) mods.push("meta");
if (e.shiftKey) mods.push("shift");
mods.sort();
let key = e.key.toLowerCase();
if (key === " ") key = "space";
if (["control", "alt", "meta", "shift"].includes(key)) {
return key;
}
return [...mods, key].join("+");
}
export function isInputTarget(e: React.KeyboardEvent): boolean {
const target = e.target as HTMLElement;
return (
target.isContentEditable ||
["INPUT", "TEXTAREA", "SELECT"].includes(target.tagName)
);
}
/**
* CORE HOOK: useKeyListener
* Low-level hook to bind a single event listener with lifecycle management.
*/
export function useKeyListener(
handler: (e: KeyboardEvent) => void,
options: UseKeyOptions = {},
) {
const { target, event = "keydown", enabled = true } = options;
const handlerRef = useRef(handler);
useIsomorphicLayoutEffect(() => {
handlerRef.current = handler;
}, []);
useEffect(() => {
if (!enabled) return;
const node = target ? target.current : window;
if (!node) return;
const eventListener = (e: Event) =>
handlerRef.current(e as unknown as KeyboardEvent);
node.addEventListener(event, eventListener);
return () => node.removeEventListener(event, eventListener);
}, [target, event, enabled]);
}
/**
* A powerful sensor hook for handling keyboard shortcuts, sequences, and modifiers.
*
* It supports complex key combinations (`Ctrl+Shift+S`), Gmail-style sequences (`g then i`),
* and provides metadata for generating "Keyboard Shortcut" UI help modals.
*
* @category sensors
* @param {KeyMap} keyMap - An object defining the key bindings and their actions.
* @param {UseKeyOptions} [options] - Global configuration options.
* @returns {{ bindings: Array<{ keys: string, category: string, description: string }> }} - Metadata about the registered bindings for UI display.
*
* @throws Will log a warning in debug mode if a key combination is invalid.
* @see [Documentation](https://sse-hooks.vercel.app/docs/hooks/use-key)
* @public
*/
export function useKey(keyMap: KeyMap, options: UseKeyOptions = {}) {
const { debug = false, sequenceTimeout = 1000, filter } = options;
const [buffer, setBuffer] = useState<string[]>([]);
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
const firedRef = useRef<Set<string>>(new Set());
const callbackMapRef = useRef(keyMap);
useIsomorphicLayoutEffect(() => {
callbackMapRef.current = keyMap;
});
const handleEvent = (e: React.KeyboardEvent) => {
// 1. Normalize
const currentCombo = normalizeKeyCombo(e);
if (debug) {
console.log(
`[useKey] Pressed: ${e.key} | Normalized: ${currentCombo} | Buffer: ${buffer.join(" ")}`,
);
}
if (filter && !filter(e)) return;
const nextBuffer = [...buffer, currentCombo];
if (timeoutRef.current) clearTimeout(timeoutRef.current);
timeoutRef.current = setTimeout(() => setBuffer([]), sequenceTimeout);
setBuffer(nextBuffer);
const sequenceKey = nextBuffer.join(" ");
Object.entries(callbackMapRef.current).forEach(([keyBinding, value]) => {
// Normalize user's binding (e.g. "Ctrl+Shift+S" -> "control+shift+s")
const normalizedBinding = keyBinding
.split(" ")
.map(normalizeKeyCombo)
.join(" ");
const config: KeyConfig =
typeof value === "function" ? { action: value } : value;
let isMatch = false;
if (normalizedBinding === sequenceKey) {
isMatch = true;
setBuffer([]);
} else if (
normalizedBinding === currentCombo &&
nextBuffer.length === 1
) {
isMatch = true;
}
if (isMatch) {
// --- Checks ---
if (config.once && firedRef.current.has(normalizedBinding)) return;
if (!config.allowInInputs && isInputTarget(e)) return;
// --- Execute ---
if (config.preventDefault) e.preventDefault();
if (config.stopPropagation) e.stopPropagation();
if (config.once) firedRef.current.add(normalizedBinding);
if (debug) console.log(`[useKey] Triggered: ${keyBinding}`);
config.action(e);
}
});
};
useKeyListener(handleEvent, options);
const bindings = useMemo(() => {
return Object.entries(keyMap).map(([keys, value]) => {
const config = typeof value === "function" ? ({} as KeyConfig) : value;
return {
keys,
category: config.category || "General",
description: config.description || "No description",
};
});
}, [keyMap]);
return { bindings };
}
import { useEffect, useRef, useState, useMemo } from "react";
import { useIsomorphicLayoutEffect } from "./use-isomorphic-layout-effect";
export const KEY_ALIASES = {
ctrl: "control",
opt: "alt",
option: "alt",
cmd: "meta",
command: "meta",
esc: "escape",
space: " ",
" ": "space",
};
export function normalizeKeyCombo(e) {
if (typeof e === "string") {
const parts = e
.toLowerCase()
.split("+")
.map((p) => p.trim());
const key = parts.pop();
const mods = parts.map((m) => KEY_ALIASES[m] || m).sort();
return [...mods, key === " " ? "space" : key].join("+");
}
const mods = [];
if (e.ctrlKey) mods.push("control");
if (e.altKey) mods.push("alt");
if (e.metaKey) mods.push("meta");
if (e.shiftKey) mods.push("shift");
mods.sort();
let key = e.key.toLowerCase();
if (key === " ") key = "space";
if (["control", "alt", "meta", "shift"].includes(key)) {
return key;
}
return [...mods, key].join("+");
}
export function isInputTarget(e) {
const target = e.target;
return (
target.isContentEditable ||
["INPUT", "TEXTAREA", "SELECT"].includes(target.tagName)
);
}
export function useKeyListener(handler, options = {}) {
const { target, event = "keydown", enabled = true } = options;
const handlerRef = useRef(handler);
useIsomorphicLayoutEffect(() => {
handlerRef.current = handler;
}, []);
useEffect(() => {
if (!enabled) return;
const node = target ? target.current : window;
if (!node) return;
const eventListener = (e) => handlerRef.current(e);
node.addEventListener(event, eventListener);
return () => node.removeEventListener(event, eventListener);
}, [target, event, enabled]);
}
export function useKey(keyMap, options = {}) {
const { debug = false, sequenceTimeout = 1000, filter } = options;
const [buffer, setBuffer] = useState([]);
const timeoutRef = useRef(null);
const firedRef = useRef(new Set());
const callbackMapRef = useRef(keyMap);
useIsomorphicLayoutEffect(() => {
callbackMapRef.current = keyMap;
});
const handleEvent = (e) => {
const currentCombo = normalizeKeyCombo(e);
if (debug) {
console.log(
`[useKey] Pressed: ${e.key} | Normalized: ${currentCombo} | Buffer: ${buffer.join(" ")}`,
);
}
if (filter && !filter(e)) return;
const nextBuffer = [...buffer, currentCombo];
if (timeoutRef.current) clearTimeout(timeoutRef.current);
timeoutRef.current = setTimeout(() => setBuffer([]), sequenceTimeout);
setBuffer(nextBuffer);
const sequenceKey = nextBuffer.join(" ");
Object.entries(callbackMapRef.current).forEach(([keyBinding, value]) => {
const normalizedBinding = keyBinding
.split(" ")
.map(normalizeKeyCombo)
.join(" ");
const config = typeof value === "function" ? { action: value } : value;
let isMatch = false;
if (normalizedBinding === sequenceKey) {
isMatch = true;
setBuffer([]);
} else if (
normalizedBinding === currentCombo &&
nextBuffer.length === 1
) {
isMatch = true;
}
if (isMatch) {
if (config.once && firedRef.current.has(normalizedBinding)) return;
if (!config.allowInInputs && isInputTarget(e)) return;
if (config.preventDefault) e.preventDefault();
if (config.stopPropagation) e.stopPropagation();
if (config.once) firedRef.current.add(normalizedBinding);
if (debug) console.log(`[useKey] Triggered: ${keyBinding}`);
config.action(e);
}
});
};
useKeyListener(handleEvent, options);
const bindings = useMemo(() => {
return Object.entries(keyMap).map(([keys, value]) => {
const config = typeof value === "function" ? {} : value;
return {
keys,
category: config.category || "General",
description: config.description || "No description",
};
});
}, [keyMap]);
return { bindings };
}
Changelog
useSSR
Custom hook that detects the current environment (Browser, Server, or Native) and capability support (Workers, EventListeners). useful for avoiding hydration mismatches.
useSymbol
Custom hook for managing ES6 Symbols. Provides utilities to create unique symbols, manage a registry of symbols, and access well-known symbols.