useKeyboardShortcut — Registra atajos de teclado con soporte de teclas modificadoras
Receta
import { useEffect, useRef, useCallback } from "react";
interface ShortcutOptions {
/** Requiere Ctrl (o Cmd en Mac). Defecto: false */
ctrl?: boolean;
/** Requiere Shift. Defecto: false */
shift?: boolean;
/** Requiere Alt (Opción en Mac). Defecto: false */
alt?: boolean;
/** Requiere Meta (Cmd en Mac, Win en Windows). Defecto: false */
meta?: boolean;
/** Llama a event.preventDefault(). Defecto: true */
preventDefault?: boolean;
/** Solo se dispara cuando esto es true. Defecto: true */
enabled?: boolean;
/** Elemento objetivo. Defecto: document */
target?: EventTarget | null;
}
function useKeyboardShortcut(
key: string,
callback: (event: KeyboardEvent) => void,
options: ShortcutOptions = {}
): void {
const {
ctrl = false,
shift = false,
alt = false,
meta = false,
preventDefault = true,
enabled = true,
target,
} = options;
const callbackRef = useRef(callback);
useEffect(() => {
callbackRef.current = callback;
}, [callback]);
useEffect(() => {
if (!enabled) return;
const eventTarget = target ?? document;
const handler = (e: Event) => {
const event = e as KeyboardEvent;
// Normaliza la comparación de teclas (sin distinción de mayúsculas)
if (event.key.toLowerCase() !== key.toLowerCase()) return;
// Verifica teclas modificadoras
// Usa metaKey O ctrlKey para Cmd/Ctrl multiplataforma
const ctrlMatch = ctrl
? event.ctrlKey || event.metaKey
: !event.ctrlKey && !event.metaKey;
// Si la opción ctrl está establecida, omite la verificación individual de meta
const metaMatch = ctrl ? true : meta ? event.metaKey : !event.metaKey;
const shiftMatch = shift ? event.shiftKey : !event.shiftKey;
const altMatch = alt ? event.altKey : !event.altKey;
if (!ctrlMatch || !shiftMatch || !altMatch || (!ctrl && !metaMatch)) {
return;
}
if (preventDefault) {
event.preventDefault();
}
callbackRef.current(event);
};
eventTarget.addEventListener("keydown", handler);
return () => eventTarget.removeEventListener("keydown", handler);
}, [key, ctrl, shift, alt, meta, preventDefault, enabled, target]);
}
/**
* useKeyboardShortcuts
* Registra múltiples atajos a la vez.
*/
function useKeyboardShortcuts(
shortcuts: Array<{
key: string;
callback: (event: KeyboardEvent) => void;
options?: ShortcutOptions;
}>
): void {
const shortcutsRef = useRef(shortcuts);
useEffect(() => {
shortcutsRef.current = shortcuts;
}, [shortcuts]);
useEffect(() => {
const handler = (e: KeyboardEvent) => {
for (const shortcut of shortcutsRef.current) {
const opts = shortcut.options ?? {};
const {
ctrl = false,
shift = false,
alt = false,
meta = false,
preventDefault = true,
enabled = true,
} = opts;
if (!enabled) continue;
if (e.key.toLowerCase() !== shortcut.key.toLowerCase()) continue;
const ctrlMatch = ctrl
? e.ctrlKey || e.metaKey
: !e.ctrlKey && !e.metaKey;
const metaMatch = ctrl ? true : meta ? e.metaKey : !e.metaKey;
const shiftMatch = shift ? e.shiftKey : !e.shiftKey;
const altMatch = alt ? e.altKey : !e.altKey;
if (!ctrlMatch || !shiftMatch || !altMatch || (!ctrl && !metaMatch)) {
continue;
}
if (preventDefault) e.preventDefault();
shortcut.callback(e);
break; // Solo dispara el primer atajo coincidente
}
};
document.addEventListener("keydown", handler);
return () => document.removeEventListener("keydown", handler);
}, []);
}Cuándo usarlo: Deseas agregar atajos de teclado como Ctrl+K para una paleta de comandos, Ctrl+S para guardar, Escape para cerrar un modal, o teclas de flecha para navegación.
Ejemplo de trabajo
"use client";
import { useState } from "react";
function CommandPalette() {
const [isOpen, setIsOpen] = useState(false);
const [query, setQuery] = useState("");
// Ctrl+K o Cmd+K abre la paleta
useKeyboardShortcut("k", () => setIsOpen(true), { ctrl: true });
// Escape la cierra
useKeyboardShortcut("Escape", () => setIsOpen(false), {
enabled: isOpen,
preventDefault: false,
});
if (!isOpen) return null;
return (
<div
style={{
position: "fixed",
inset: 0,
background: "rgba(0,0,0,0.5)",
display: "flex",
alignItems: "flex-start",
justifyContent: "center",
paddingTop: 100,
zIndex: 1000,
}}
>
<div
style={{
background: "#fff",
borderRadius: 12,
padding: 16,
width: 500,
maxWidth: "90vw",
boxShadow: "0 16px 48px rgba(0,0,0,0.2)",
}}
>
<input
autoFocus
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Escribe un comando..."
style={{
width: "100%",
padding: 12,
fontSize: 16,
border: "1px solid #e0e0e0",
borderRadius: 8,
outline: "none",
}}
/>
<div style={{ marginTop: 8, color: "#666", fontSize: 14 }}>
Presiona Escape para cerrar
</div>
</div>
</div>
);
}
function EditorWithShortcuts() {
const [content, setContent] = useState("Hola, mundo!");
const [saved, setSaved] = useState(false);
// Ctrl+S para guardar
useKeyboardShortcut("s", () => {
console.log("Guardando:", content);
setSaved(true);
setTimeout(() => setSaved(false), 2000);
}, { ctrl: true });
// Ctrl+Shift+Z para rehacer
useKeyboardShortcut("z", () => {
console.log("Rehacer");
}, { ctrl: true, shift: true });
return (
<div>
<textarea
value={content}
onChange={(e) => setContent(e.target.value)}
style={{ width: "100%", height: 200 }}
/>
{saved && <span style={{ color: "green" }}>¡Guardado!</span>}
</div>
);
}Lo que esto demuestra:
- Ctrl+K / Cmd+K abre una paleta de comandos (manejo de modificadores multiplataforma)
- Escape cierra la paleta solo cuando está abierta (opción
enabled) - Ctrl+S evita el diálogo de guardar del navegador e dispara lógica de guardado personalizada
- Ctrl+Shift+Z demuestra modificadores compuestos para rehacer
Profundización
Cómo funciona
- Modificadores multiplataforma: Cuando
ctrl: true, el hook coincide conevent.ctrlKey(Windows/Linux) yevent.metaKey(Mac Cmd), por lo queCtrl+KyCmd+Kfuncionan. - Patrón de callback ref: El callback se almacena en un ref para que el listener de eventos no necesite volver a suscribirse cuando el callback cambia.
- Normalización de teclas:
event.keyse compara sin distinción de mayúsculas, por lo que"k"coincide conkyK(con Shift). - preventDefault: Por defecto es
truepara evitar los valores predeterminados del navegador (p. ej., Ctrl+S abriendo el diálogo Guardar). Establece enfalsepara teclas como Escape donde deseas que el comportamiento predeterminado continúe. - Protección enabled: Cuando es
false, el efecto omite el registro completamente, evitando listeners innecesarios. - Variante multi-atajo:
useKeyboardShortcutsregistra un listener único para múltiples atajos, interrumpiendo después del primer coincidencia para eficiencia.
Parámetros y valores devueltos
| Parámetro | Tipo | Defecto | Descripción |
|---|---|---|---|
key | string | — | El valor event.key (p. ej., "k", "Escape", "ArrowDown") |
callback | (event: KeyboardEvent) => void | — | Manejador a llamar cuando se dispara el atajo |
options.ctrl | boolean | false | Requiere Ctrl (o Cmd en Mac) |
options.shift | boolean | false | Requiere Shift |
options.alt | boolean | false | Requiere Alt (Opción en Mac) |
options.meta | boolean | false | Requiere Meta (Cmd en Mac) |
options.preventDefault | boolean | true | Llama a event.preventDefault() |
options.enabled | boolean | true | Si el atajo está activo |
options.target | EventTarget | document | Objetivo de evento personalizado |
Variaciones
Secuencia de teclas (acorde): Detecta secuencias de múltiples teclas como g seguido de h para navegación estilo GitHub:
function useKeySequence(keys: string[], callback: () => void, timeout = 1000) {
const indexRef = useRef(0);
const timerRef = useRef<ReturnType<typeof setTimeout>>();
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key.toLowerCase() === keys[indexRef.current].toLowerCase()) {
indexRef.current++;
clearTimeout(timerRef.current);
if (indexRef.current === keys.length) {
callback();
indexRef.current = 0;
} else {
timerRef.current = setTimeout(() => {
indexRef.current = 0;
}, timeout);
}
} else {
indexRef.current = 0;
}
};
document.addEventListener("keydown", handler);
return () => document.removeEventListener("keydown", handler);
}, [keys, callback, timeout]);
}Limitado a elemento: Pasa un ref como target para solo escuchar atajos cuando un elemento específico tiene enfoque:
const inputRef = useRef<HTMLInputElement>(null);
useKeyboardShortcut("Enter", handleSubmit, {
target: inputRef.current,
preventDefault: false,
});Notas de TypeScript
- Los valores de
event.keyson strings de la especificaciónKeyboardEvent.key(p. ej.,"Escape","ArrowUp","a"). - La interfaz de opciones utiliza todas las propiedades opcionales con valores predeterminados sensatos.
- El callback recibe el
KeyboardEventcompleto para inspección avanzada (p. ej.,event.repeatpara teclas mantenidas).
Trampas
- Los campos de entrada absorben atajos — Escribir
ken una entrada de texto disparaCtrl+Ksi Ctrl se mantiene presionado. Solución: Verificaevent.targety omite si es un elemento input, textarea o contenteditable. - Atajos reservados del navegador — Algunos atajos como Ctrl+W (cerrar pestaña) o Ctrl+T (nueva pestaña) no se pueden sobrescribir. Solución: Elige atajos que los navegadores no reserven. Ctrl+K generalmente es seguro.
- Mac vs Windows — Los usuarios de Mac esperan Cmd, no Ctrl. Solución: La opción
ctrlcoincide conctrlKeyymetaKeypor defecto para compatibilidad multiplataforma. - key vs code —
event.keyrefleja el carácter ("a"), mientras queevent.coderefleja la tecla física ("KeyA"). Los diseños que no son QWERTY pueden diferir. Solución: Usaevent.keypara atajos de caracteres,event.codepara atajos basados en posición. - Eventos de repetición — Mantener una tecla dispara eventos
keydownrepetidos. Solución: Verificaevent.repeaty omite si solo deseas el primer presionamiento.
Alternativas
| Paquete | Nombre del Hook | Notas |
|---|---|---|
react-hotkeys-hook | useHotkeys | Más popular, atajos basados en strings |
ahooks | useKeyPress | Detección simple de presión de tecla |
@uidotdev/usehooks | useKeyPress | Minimal, tecla única |
cmdk | Built-in | Componente de paleta de comandos completo |
kbar | Built-in | Paleta de comandos con manejo de atajos |
Preguntas frecuentes
¿Cómo maneja el hook las diferencias entre Ctrl y Cmd multiplataforma?
Cuando ctrl: true está establecido, el hook coincide con event.ctrlKey (Windows/Linux) y event.metaKey (Mac Cmd). Esto significa que Ctrl+K en Windows y Cmd+K en Mac disparan el mismo atajo.
¿Por qué preventDefault tiene por defecto true?
La mayoría de los atajos de teclado anulan los valores predeterminados del navegador (p. ej., Ctrl+S dispara el diálogo Guardar). Establecer preventDefault: true detiene la acción predeterminada del navegador. Establécelo en false para teclas como Escape donde deseas que el comportamiento predeterminado continúe.
¿Cuál es la diferencia entre useKeyboardShortcut y useKeyboardShortcuts (plural)?
useKeyboardShortcutregistra un listenerkeydownúnico para un atajo.useKeyboardShortcutsregistra un listenerkeydownúnico que verifica múltiples atajos, interrumpiendo después del primer coincidencia. Es más eficiente para muchos atajos.
¿Cómo funciona la opción enabled?
Cuando enabled es false, el efecto omite el registro completamente. No hay un listener de eventos adjunto al documento. Esto es útil para atajos que solo deben estar activos en ciertos estados (p. ej., Escape solo cuando un modal está abierto).
Trampa: Mi atajo Ctrl+K se dispara cuando escribo "k" en una entrada de texto con Ctrl presionado. ¿Cómo lo evito?
Verifica event.target dentro del callback y omite si es un elemento input, textarea o contenteditable:
useKeyboardShortcut("k", (e) => {
const tag = (e.target as HTMLElement).tagName;
if (tag === "INPUT" || tag === "TEXTAREA") return;
setIsOpen(true);
}, { ctrl: true });Trampa: Mantener una tecla dispara el atajo repetidamente. ¿Cómo hago que se dispare solo una vez?
Verifica event.repeat dentro del callback:
useKeyboardShortcut("s", (e) => {
if (e.repeat) return;
save();
}, { ctrl: true });¿Puedo anular atajos reservados del navegador como Ctrl+W o Ctrl+T?
No. Los navegadores reservan ciertos atajos (cerrar pestaña, nueva pestaña) y no pueden ser interceptados por JavaScript. Elige atajos que los navegadores no reserven. Ctrl+K generalmente es seguro.
¿Cuál es la diferencia entre event.key y event.code?
event.keyrefleja el carácter producido ("a", "k", "Escape").event.coderefleja la posición de la tecla física ("KeyA", "KeyK").- Los diseños que no son QWERTY pueden producir diferentes caracteres para la misma tecla física.
- Este hook usa
event.keypara atajos basados en caracteres.
¿Cómo se escriben las opciones de tecla y modificador en TypeScript?
El parámetro key es un string plano que coincide con valores de KeyboardEvent.key. La interfaz ShortcutOptions utiliza todas las propiedades booleanas opcionales (ctrl, shift, alt, meta) con valores predeterminados desestructurados en el cuerpo de la función.
¿Cómo implementaría una secuencia de teclas (acorde) como la de GitHub g seguido de h?
Usa la variación useKeySequence de la sección Variaciones. Rastrea la posición actual en el array de teclas mediante un ref y se reinicia después de un timeout si la secuencia no se completa.
Relacionado
- useEventListener — el patrón de listener subyacente
- useClickOutside — descartar UI con clics
- useToggle — gestiona el estado abierto/cerrado de paletas