Como Adicionar Atalhos de Teclado a uma Aplicação Web
Como adicionar atalhos de teclado a uma web app com listener keydown global, proteção أثناء digitação, modificadores Mac e Windows, sequências e cleanup no React.
Para adicionar atalhos de teclado a uma aplicação web, registre um único listener de keydown no document, faça a correspondência com event.key somado aos booleanos de modificadores, ignore o evento quando seu alvo for um elemento editável e remova o listener usando a mesma referência de função quando o componente responsável for desmontado.
O primeiro atalho normalmente é rápido de escrever. Os problemas tendem a aparecer depois: alguém digita “k” em um campo de busca e a paleta de comandos abre, ou um colega no Mac descobre que o atalho simplesmente não faz nada.
Este artigo parte daquele listener ingênuo e corrige cada falha por vez: disparos durante a digitação, modificadores no Mac versus Windows, sequências de duas teclas, vazamentos de listeners no React e as regras de acessibilidade que se aplicam especificamente a atalhos. A maioria das correções são poucas linhas de TypeScript que você pode inserir em um handler já existente.
Principais Conclusões
- Um listener de
keydownnodocumentrecebe todas as teclas pressionadas na página, então o handler deve retornar antecipadamente quandoevent.targetfor uminput,textarea,selectou qualquer elemento cujoisContentEditableseja verdadeiro. - Um atalho que testa apenas
event.ctrlKeynunca dispara em um Mac, porque a tecla Command defineevent.metaKey; testeevent.metaKey || event.ctrlKeypara que uma única associação cubra ambas as plataformas. - A correspondência não precisa de detecção de plataforma; resolva a plataforma apenas para exibição, que é o único uso documentado pela MDN para
navigator.platform. - Uma sequência como
gseguido deiprecisa de um buffer, um timeout de expiração, um reset ao pressionar qualquer tecla que não seja prefixo e uma nova verificação dessa tecla como início de uma nova sequência. - No React, registre o listener no
useEffect, remova a mesma referência na limpeza e memoize o handler comuseCallbackcaso ele leia props ou estado.
O Listener Ingênuo de Atalhos de Teclado em JavaScript
O atalho funcional mais simples é um listener de keydown que compara event.key com um caractere e chama preventDefault() em caso de correspondência. Use event.key, nunca o obsoleto keyCode.
document.addEventListener('keydown', (event) => {
if (event.ctrlKey && event.key.toLowerCase() === 'k') {
event.preventDefault();
openCommandPalette();
}
});
Converter event.key para minúsculas faz a correspondência sobreviver ao Caps Lock e ao Shift. Todo o resto desse listener é um bug esperando por um usuário.
Como Impedir que Atalhos Disparem Enquanto o Usuário Está Digitando?
Um handler de atalho deve verificar event.target antes de fazer qualquer coisa, porque um listener no nível do documento também recebe as teclas que o usuário digita em um campo de busca. O filtro comum verifica três nomes de tag, e esse modelo mental é justamente a brecha: uma região contenteditable mantém sua própria tag (geralmente div), então um editor de rich text passa pela verificação e o atalho dispara no meio da frase. As gravações de sessão de aplicações com atalhos globais mostram exatamente isso: um usuário digitando em um campo e a página navegando para outro lugar por causa da letra que estava associada.
Use isContentEditable em vez disso. Ele é verdadeiro para qualquer elemento que o usuário possa editar, incluindo um que herda a edição de um ancestral:
function isTyping(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) return false;
const tag = target.tagName;
return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT'
|| target.isContentEditable;
}
document.addEventListener('keydown', (event) => {
if (isTyping(event.target)) return;
// matching below
});
Retorne no topo do handler para que nada a jusante, incluindo o buffer de sequência de uma seção posterior, jamais veja uma tecla digitada.
Como Lidar com as Teclas Modificadoras de Mac e Windows?
Um atalho que testa apenas event.ctrlKey está morto em um Mac, porque a tecla Command define event.metaKey. Aceite qualquer um dos modificadores na correspondência:
const mod = event.metaKey || event.ctrlKey;
if (mod && event.key.toLowerCase() === 'k') { /* ... */ }
Isso corresponde de forma ligeiramente ampla demais (Ctrl+K também funciona em um Mac), o que é inofensivo. O que se evita é a detecção de plataforma no caminho de correspondência. navigator.platform é documentado como não confiável para detecção, e o único uso que a MDN endossa é escolher entre ⌘ e Ctrl ao exibir um atalho para o usuário. Mantenha-o restrito a isso:
| Tecla física | Propriedade do evento | Exibição |
|---|---|---|
| Command (macOS) | metaKey | ⌘ |
| Control (Windows/Linux) | ctrlKey | Ctrl |
| Tecla Windows | metaKey | Não associar |
const isMac = navigator.platform.startsWith('Mac') || navigator.platform === 'iPhone';
const formatKeys = (keys: string[]) =>
keys.map((k) => (k === 'mod' ? (isMac ? '⌘' : 'Ctrl') : k)).join(isMac ? '' : '+');
Como Dar Suporte a Sequências de Teclas Como g Seguido de i?
Uma sequência de duas teclas precisa de um buffer, um timeout que o limpe, um reset quando o buffer deixar de ser um prefixo válido e uma nova verificação da tecla problemática como início de uma nova sequência. Descartar essa tecla obriga o usuário a pressioná-la duas vezes. Mais duas regras: ignore keydowns em que event.repeat seja verdadeiro, para que uma tecla mantida pressionada não inunde o buffer, e ignore keydowns de modificadores puros (Shift, Control, Meta, Alt, AltGraph), ou pressionar Shift antes de um acorde cancelará qualquer sequência em andamento.
| Buffer | Resultado após adicionar a tecla | Ação |
|---|---|---|
| qualquer | igual a uma associação | executá-la, limpar o buffer |
| qualquer | prefixo de uma associação | manter o buffer, reiniciar o timeout |
| tamanho > 1 | não corresponde a nada | limpar o buffer, alimentar a tecla novamente sozinha |
| tamanho 1 | não corresponde a nada | limpar o buffer |
| qualquer | timeout dispara | limpar o buffer |
type Binding = { keys: string[]; description: string; run: () => void };
const bindings: Binding[] = [
{ keys: ['g', 'i'], description: 'Go to inbox', run: () => navigate('/inbox') },
{ keys: ['g', 'p'], description: 'Go to projects', run: () => navigate('/projects') },
];
const MODIFIERS = new Set(['Control', 'Meta', 'Shift', 'Alt', 'AltGraph']);
let buffer: string[] = [];
let timer: ReturnType<typeof setTimeout> | undefined;
function reset() { buffer = []; clearTimeout(timer); }
function feed(key: string) {
buffer.push(key);
const exact = bindings.find(
(b) => b.keys.length === buffer.length && b.keys.every((k, i) => k === buffer[i]),
);
if (exact) { exact.run(); reset(); return; }
if (bindings.some((b) => buffer.every((k, i) => b.keys[i] === k))) {
clearTimeout(timer);
timer = setTimeout(reset, 800);
return;
}
const retry = buffer.length > 1;
reset();
if (retry) feed(key);
}
document.addEventListener('keydown', (event) => {
if (isTyping(event.target) || event.repeat || MODIFIERS.has(event.key)) return;
if (event.metaKey || event.ctrlKey || event.altKey) return; // chords go elsewhere
feed(event.key.toLowerCase());
});
A janela de 800 ms é uma escolha, não uma medição; algumas centenas de milissegundos é o valor típico.
Como Registrar e Limpar um Listener de Atalho no React?
No React, adicione o listener dentro do useEffect e remova a mesma referência de função na limpeza; se o handler ler props ou estado, memoize-o com useCallback e inclua-o no array de dependências do effect. Sem a limpeza, cada nova montagem empilha mais um listener e um único pressionamento de tecla executa a ação duas vezes.
function useShortcuts(bindings: Binding[]) {
const handleKeyDown = useCallback((event: KeyboardEvent) => {
if (isTyping(event.target)) return;
// match against bindings here
}, [bindings]);
useEffect(() => {
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [handleKeyDown]);
}
Atenção à dependência bindings nesse hook. Se quem chama passa um array literal inline, ele é um array diferente a cada renderização, então handleKeyDown muda de identidade e o effect remove e readiciona o listener todas as vezes. Nada quebra, mas essa rotatividade é trabalho desperdiçado. Declare o array no nível do módulo ou envolva-o em useMemo no componente que faz a chamada.
Desde o React 18, o Strict Mode submete cada Effect a uma rodada extra de setup e teardown em desenvolvimento, então uma limpeza que remove uma referência diferente daquela que foi adicionada aparece imediatamente como um handler duplicado. Fora do React a regra é idêntica: um addEventListener, um removeEventListener correspondente, mesma função.
Mantenha os Atalhos Acessíveis e Descobríveis
Três regras se aplicam especificamente a atalhos. Não associe combinações reservadas pelo navegador, incluindo Cmd/Ctrl+W, Cmd/Ctrl+N, Cmd/Ctrl+T e Tab, e não chame preventDefault() em acordes nativos de edição como Cmd/Ctrl+C. Nunca faça de um atalho a única rota para um recurso; um item de menu ou botão deve existir para a mesma ação. E para associações de caractere único, o WCAG 2.1 SC 2.1.4 Character Key Shortcuts (Nível A) exige uma de três coisas: uma forma de as pessoas desativarem o atalho, uma forma de reassociá-lo para que inclua uma tecla como Ctrl ou Alt, ou um escopo restrito o suficiente para que ele só dispare enquanto seu próprio componente tiver o foco.
Para a descobribilidade, associe ? a um diálogo de ajuda que renderize o mesmo array bindings usado pelo matcher. Faça a correspondência com event.key === '?' em vez de Shift mais a tecla de barra, para que funcione em layouts nos quais ? fica em uma tecla física diferente.
if (event.key === '?' && !isTyping(event.target)) {
event.preventDefault();
helpDialog.showModal();
}
// inside the dialog
{bindings.map((b) => (
<li key={b.keys.join(' ')}><kbd>{formatKeys(b.keys)}</kbd> {b.description}</li>
))}
O showModal() em um <dialog> nativo lhe dá o tratamento da tecla Escape de graça; para o gerenciamento de foco dentro dele, veja o guia sobre problemas comuns de acessibilidade com modais.
Quando Recorrer a uma Biblioteca de Atalhos?
Assim que você tiver mais do que algumas poucas associações, escopo, detecção de conflitos e tratamento de sequências passam a valer a delegação. O TanStack Hotkeys é uma opção: uma tecla Mod em uma associação é resolvida como Command no Mac e como Control em todos os outros lugares, e as teclas direcionadas a elementos de entrada em foco são ignoradas automaticamente. Sua página de visão geral ainda classifica a biblioteca como alpha e avisa que a API pode mudar, então fixe sua versão e espere instabilidade.
Conclusão
Atalhos quebram em lugares previsíveis: o alvo do evento, a tecla modificadora, o buffer de sequência e o ciclo de vida do listener. Comece com a guarda isTyping e a correspondência metaKey || ctrlKey no handler que você já tem, depois mova suas associações para um único array, de modo que o matcher e o diálogo de ajuda do ? leiam da mesma fonte.
Perguntas Frequentes
Qual é a diferença entre event.key e event.code para atalhos de teclado?
event.key fornece o caractere que uma tecla produz depois de considerados o layout do teclado e quaisquer modificadores pressionados, enquanto event.code nomeia a posição física da tecla e permanece a mesma independentemente do layout. Faça a correspondência dos atalhos com event.key para que uma associação de 'k' signifique a letra impressa na tecla, em qualquer teclado. Reserve event.code para entradas baseadas em posição, como WASD em jogos. O TanStack Hotkeys recorre a event.code apenas para teclas de letras e dígitos, e somente quando event.key retorna um caractere especial no lugar, como acontece com Option mais uma letra no macOS.
Devo usar keydown, keyup ou keypress para atalhos de teclado?
Use keydown. A MDN marca keypress como obsoleto, e ele só dispara para teclas que produzem um caractere, então nunca reporta Escape, teclas de seta ou um modificador pressionado sozinho. keydown dispara para todas as teclas, expõe event.key e os booleanos de modificadores, e é o evento em que preventDefault interrompe a ação própria do navegador. keyup chega depois que o navegador já agiu sobre o keydown, então não pode suprimir um atalho nativo nem um caractere inserido.
Os atalhos de teclado disparam enquanto um usuário digita com um IME, como entrada de japonês ou chinês?
Sim. Um listener de keydown no nível do documento continua recebendo as teclas enquanto um IME está compondo, então retorne antecipadamente quando event.isComposing for verdadeiro. A flag permanece verdadeira para todo evento de tecla entre o momento em que o IME abre uma sessão de composição e o momento em que a encerra, que é exatamente a janela em que seus atalhos devem ficar fora do caminho. A guarda isTyping cobre a maioria dos casos porque a composição acontece em um elemento editável, mas isComposing acrescenta uma segunda verificação para superfícies de texto customizadas.