Se você é como eu e adora atalhos, sabe o quão satisfatório é pressionar algumas teclas e ver a mágica acontecer. Seja o familiar Ctrl+C – Ctrl+V que os desenvolvedores usam para “pegar emprestado código” 😉 de LLMs e páginas de código, ou os atalhos personalizados que configuramos em nossas ferramentas favoritas, os atalhos de teclado economizam tempo e nos fazem sentir como um gênio da computação.

Bem, não tema! Eu decifrei o código para construir componentes que acionam e respondem a atalhos de teclado. Neste artigo, vou te ensinar como criá-los com React, Tailwind CSS e Framer Motion.

Tabela de Conteúdo

Aqui está tudo que vamos abordar:

Pré-requisitos

  • Fundamentos de HTML, CSS e Tailwind CSS

  • Fundamentos de JavaScript, React e React Hooks.

O Que É um Componente de Listener de Atalho de Teclado (KSL)?

Um Componente Ouvinte de Atalhos de Teclado (KSLC) é um componente que escuta combinações de teclas específicas e aciona ações no seu aplicativo. Ele é projetado para fazer seu aplicativo responder a atalhos de teclado, permitindo uma experiência de usuário mais suave e eficiente.

Por que isso é importante?

  • Acessibilidade: O componente KSL torna simples para as pessoas que usam um teclado acionarem ações, tornando seu aplicativo mais inclusivo e fácil de usar.

  • Experiência Mais Ágil: Os atalhos são rápidos e eficientes, permitindo que os usuários realizem tarefas em menos tempo. Nada de procurar o mouse—basta pressionar uma tecla (ou duas) e pronto, a ação acontece!

  • Reutilização: Depois de configurar seu KSL, ele pode lidar com diferentes atalhos em todo o seu aplicativo, tornando fácil adicionar sem reescrever a mesma lógica.

  • Código mais Limpo: Em vez de espalhar ouvintes de eventos de teclado por toda parte, o componente KSL mantém as coisas organizadas centralizando a lógica. Seu código permanece limpo, organizado e mais fácil de manter.

Como Construir o Componente KSL

Preparei um repositório no GitHub com arquivos iniciais para acelerar o processo. Basta clonar este repositório e instalar as dependências.

Para este projeto, estamos usando a página inicial do Tailwind como inspiração e criando a funcionalidade KSL. Após instalar e executar o comando de compilação, veja como sua página deve ficar:

Como Criar o Componente de Revelação

O componente de revelação é o componente que queremos mostrar quando usarmos o atalho.

Para começar, crie um arquivo chamado search-box.tsx e cole este código:

export default function SearchBox() {
  return (
    <div className="fixed top-0 left-0 w-full h-full backdrop-blur-sm bg-slate-900/50 ">
      {" "}
      <div className=" p-[15vh] text-[#939AA7] h-full">
        <div className="max-w-xl mx-auto divide-y divide-[#939AA7] bg-[#1e293b] rounded-md">
          <div className="relative flex justify-between px-4 py-2 text-sm ">
            <div className="flex items-center w-full gap-2 text-white">
              <BiSearch size={20} />
              <input
                type="text"
                className="w-full h-full p-2 bg-transparent focus-within:outline-none"
                placeholder="Search Documentation"
              />
            </div>
            <div className="absolute -translate-y-1/2 right-4 top-1/2 ">
              <kbd className="p-1 text-xs rounded-[4px] bg-[#475569] font-sans font-semibold text-slate-400">
                <abbr title="Escape" className="no-underline ">
                  Esc{" "}
                </abbr>{" "}
              </kbd>
            </div>
          </div>
          <div className="flex items-center justify-center p-10 text-center ">
            <h2 className="text-xl">
              How many licks does it take to get to the center of a Tootsie pop?
            </h2>
          </div>
        </div>
      </div>
    </div>
  );
}

Ok, então o que está acontecendo neste código?

  1. Sobreposição Principal (<div className="fixed top-0 left-0 ...">)

    • Esta é a sobreposição em tela cheia que escurece o plano de fundo.

    • O backdrop-blur-sm adiciona um leve desfoque ao plano de fundo, e bg-slate-900/50 dá a ele uma sobreposição escura semitransparente.

  2. Wrapper da Caixa de Pesquisa (<div className="p-[15vh] ...">)

    • O conteúdo é centralizado usando padding e utilitários de flex.

    • A max-w-xl garante que a caixa de pesquisa permaneça dentro de uma largura razoável para legibilidade.

Então, no seu App.tsx, crie um estado que mostra dinamicamente esse componente:

const [isOpen, setIsOpen] = useState<boolean>(false);
  • useState: Este hook inicializa isOpen como false, significando que a caixa de pesquisa está oculta por padrão.

  • Quando isOpen é definido como true, o componente SearchBox será renderizado na tela.

E renderize o componente de busca:

  {isOpen && <SearchBox />}

Para mostrar o componente de busca, adicione uma função de alternância ao botão de entrada:

<button
  type="button"
  className="items-center hidden h-12 px-4 space-x-3 text-left rounded-lg shadow-sm sm:flex w-72 ring-slate-900/10 focus:outline-none hover:ring-2 hover:ring-sky-500 focus:ring-2 focus:ring-sky-500 bg-slate-800 ring-0 text-slate-300 highlight-white/5 hover:bg-slate-700"
  onClick={() => setIsOpen(true)}>
  <BiSearch size={20} />
  <span className="flex-auto">Quick search...</span>
   <kbd className="font-sans font-semibold text-slate-500">
   <abbr title="Control" className="no-underline text-slate-500">
    Ctrl{" "}
    </abbr>{" "}
    K
   </kbd>
</button>

O evento onClick define isOpen como true, exibindo o SearchBox.

Mas como você viu, isso foi acionado por uma ação de clique, não por um atalho de teclado. Vamos fazer isso a seguir.

Como Acionar o Componente via Atalho de Teclado

Para fazer o componente de revelação abrir e fechar usando um atalho de teclado, usaremos um hook useEffect para escutar combinações de teclas específicas e atualizar o estado do componente de acordo.

Passo 1: Escutar Eventos de Teclado

Adicione um hook useEffect no seu arquivo App.tsx para escutar pressionamentos de tecla:

  useEffect(() => {
    const handleKeyDown = (event: KeyboardEvent) => {
      if (event.ctrlKey && event.key === Key.K) {
        event.preventDefault(); // Prevenir comportamento padrão do navegador

      }    };

    window.addEventListener("keydown", handleKeyDown);
    return () => {
      window.removeEventListener("keydown", handleKeyDown);
    };
  }, []);

O que está acontecendo neste código?

  1. Configuração do Efeito (useEffect)

    • useEffect garante que o ouvinte de eventos para pressionamentos de tecla seja adicionado quando o componente monta e limpo quando o componente desmonta, prevenindo vazamentos de memória.
  2. Combinação de Teclas (event.ctrlKey && event.key === "k")

    • O event.ctrlKey verifica se a tecla Control está sendo pressionada.

    • O event.key === "k" garante que estamos ouvindo especificamente a tecla “K”. Juntas, isso verifica se a combinação Ctrl + K foi pressionada.

  3. Prevenir Comportamento Padrão (event.preventDefault())

    • Alguns navegadores podem ter comportamentos padrão associados a combinações de teclas como Ctrl + K (por exemplo, focar na barra de endereços do navegador). Chamar preventDefault interrompe esse comportamento.
  4. Limpeza de Evento (return () => ...)

    • A função de limpeza remove o ouvinte de evento para evitar que ouvintes duplicados sejam adicionados se o componente for re-renderizado.

Passo 2: Alternar Visibilidade do Componente

Em seguida, atualize a função handleKeyDown para alternar a visibilidade do SearchBox quando o atalho for pressionado:

useEffect(() => {
    const handleKeyDown = (event: KeyboardEvent) => {
      // Ouvir Ctrl + K
      if (event.ctrlKey && event.key === Key.K) {
        event.preventDefault(); // Prevenir comportamento padrão do navegador
        setIsOpen((prev) => !prev); // Alternar a caixa de pesquisa
      } else if (event.key === Key.Escape) {
        setIsOpen(false); // Fechar a caixa de pesquisa
      }
    };

    window.addEventListener("keydown", handleKeyDown);
    return () => {
      window.removeEventListener("keydown", handleKeyDown);
    };
  }, []);

O que está acontecendo neste código?

  1. Alternando Estado (setIsOpen((prev) => !prev))

    • Quando Ctrl + K é pressionado, o definidor de estado setIsOpen alterna a visibilidade da SearchBox.

    • O argumento prev representa o estado anterior. Usando !prev inverte seu valor:

      • true (aberto) se torna false (fechado).

      • false (fechado) se torna true (aberto).

  2. Fechando com a Tecla Escape (event.key === "Escape")

    • Quando a tecla Escape é pressionada, setIsOpen(false) define explicitamente o estado como false, fechando o SearchBox.

Isso resulta no seguinte:

Como Animar a Visibilidade do Componente

No momento, nosso componente funciona, mas falta um pouco de estilo, não acha? Vamos mudar isso.

Passo 1: Criar o Componente Overlay

Vamos começar criando um componente overlay, que atua como o fundo escuro e desfocado para a caixa de pesquisa. Aqui está a versão base:

import { ReactNode } from "react";

export default function OverlayWrapper({ children }: { children: ReactNode }) {
  return (
    <div
      className="fixed top-0 left-0 w-full h-full backdrop-blur-sm bg-slate-900/50 ">
      {children}
    </div>
  );
}

Passo 2: Adicionar Animações ao Overlay

Agora, vamos fazer o overlay aparecer e desaparecer usando o Framer Motion. Atualize o componente OverlayWrapper assim:

import { motion } from "framer-motion";
import { ReactNode } from "react";

export default function OverlayWrapper({ children }: { children: ReactNode }) {
  return (
    <motion.div
      initial={{ opacity: 0 }}
      animate={{ opacity: 1 }}
      exit={{ opacity: 0 }}
      className="fixed top-0 left-0 w-full h-full backdrop-blur-sm bg-slate-900/50 ">
      {children}
    </motion.div>
  );
}
Principais propriedades de animação:
  • initial: Define o estado inicial quando o componente é montado (totalmente transparente).

  • animate: Define o estado para o qual animar (totalmente opaco).

  • sair: Especifica a animação quando o componente é desmontado (desvanecendo).

Em seguida, adicione um pouco de movimento à própria caixa de pesquisa. Faremos com que ela deslize e apareça ao desvanecer e deslize para fora ao desaparecer.

import { motion } from "framer-motion";
import { BiSearch } from "react-icons/bi";
import OverlayWrapper from "./overlay";

export default function SearchBox() {
  return (
    <OverlayWrapper>
      <motion.div
        initial={{ y: "-10%", opacity: 0 }}
        animate={{ y: "0%", opacity: 1 }}
        exit={{ y: "-5%", opacity: 0 }}
        className=" p-[15vh] text-[#939AA7] h-full">
        <div
          className="max-w-xl mx-auto divide-y divide-[#939AA7] bg-[#1e293b] rounded-md"
        >
          <div className="relative flex justify-between px-4 py-2 text-sm ">
            <div className="flex items-center w-full gap-2 text-white">
              <BiSearch size={20} />
              <input
                type="text"
                className="w-full h-full p-2 bg-transparent focus-within:outline-none"
                placeholder="Search Documentation"
              />
            </div>
            <div className="absolute -translate-y-1/2 right-4 top-1/2 ">
              <kbd className="p-1 text-xs rounded-[4px] bg-[#475569] font-sans font-semibold text-slate-400">
                <abbr title="Escape" className="no-underline ">
                  Esc{" "}
                </abbr>{" "}
              </kbd>
            </div>
          </div>
          <div className="flex items-center justify-center p-10 text-center ">
            <h2 className="text-xl">
              How many licks does it take to get to the center of a Tootsie pop?
            </h2>
          </div>
        </div>
      </motion.div>
    </OverlayWrapper>
  );
}

Passo 4: Habilitar Rastreamento de Animação com AnimatePresence

Por fim, envolva sua lógica de renderização condicional no componente AnimatePresence fornecido pelo Framer Motion. Isso garante que o Framer Motion rastreie quando os elementos entram e saem do DOM.

<AnimatePresence>{isOpen && <SearchBox />}</AnimatePresence>

Isso permite que o Framer Motion rastreie quando um elemento entra e sai do DOM. Com isso, obtemos o seguinte resultado:

Ah, muito melhor!

Como Otimizar Seu Componente KSL

Se você achou que tínhamos terminado, não tão rápido… Ainda temos um pouco mais a fazer.

Precisamos otimizar para acessibilidade. Devemos adicionar uma maneira para os usuários fecharem o componente de pesquisa com o mouse, pois a acessibilidade é muito importante.

Para fazer isso, comece criando um hook chamado useClickOutside. Esse hook usa um elemento de referência para saber quando um usuário está clicando fora do elemento alvo (caixa de pesquisa), o que é um comportamento muito popular para fechar modais e KSLCs.


import { useEffect } from "react";

type ClickOutsideHandler = (event: Event) => void;

export const useClickOutside = (
  ref: React.RefObject<HTMLElement>,
  handler: ClickOutsideHandler
) => {
  useEffect(() => {
    const listener = (event: Event) => {
      // Não fazer nada se estiver clicando no elemento de referência ou em elementos descendentes
      if (!ref.current || ref.current.contains(event.target as Node)) return;

      handler(event);
    };

    document.addEventListener("mousedown", listener);
    document.addEventListener("touchstart", listener);

    return () => {
      document.removeEventListener("mousedown", listener);
      document.removeEventListener("touchstart", listener);
    };
  }, [ref, handler]);
};

Para usar esse hook, passe a função responsável por abrir e fechar o componente de pesquisa:

<AnimatePresence> {isOpen && <SearchBox close={setIsOpen} />} </AnimatePresence>

Então receba a função na busca com seu tipo de propriedade apropriado:

export default function SearchBox({
  close,
}: {
  close: React.Dispatch<React.SetStateAction<boolean>>;
}) {

Depois disso, crie uma referência (ref) para o item que você deseja rastrear e marque esse elemento:

import { motion } from "framer-motion";
import { useRef } from "react";
import { BiSearch } from "react-icons/bi";
import { useClickOutside } from "../hooks/useClickOutside";
import OverlayWrapper from "./overlay";

export default function SearchBox({
  close,
}: {
  close: React.Dispatch<React.SetStateAction<boolean>>;
}) {
  const searchboxRef = useRef<HTMLDivElement>(null);
  return (
    <OverlayWrapper>
      <motion.div
        initial={{ y: "-10%", opacity: 0 }}
        animate={{ y: "0%", opacity: 1 }}
        exit={{ y: "-5%", opacity: 0 }}
        className=" p-[15vh] text-[#939AA7] h-full">
        <div
          className="max-w-xl mx-auto divide-y divide-[#939AA7] bg-[#1e293b] rounded-md"
          ref={searchboxRef}>
          <div className="relative flex justify-between px-4 py-2 text-sm ">
            <div className="flex items-center w-full gap-2 text-white">
              <BiSearch size={20} />
              <input
                type="text"
                className="w-full h-full p-2 bg-transparent focus-within:outline-none"
                placeholder="Search Documentation"
              />
            </div>
            <div className="absolute -translate-y-1/2 right-4 top-1/2 ">
              <kbd className="p-1 text-xs rounded-[4px] bg-[#475569] font-sans font-semibold text-slate-400">
                <abbr title="Escape" className="no-underline ">
                  Esc{" "}
                </abbr>{" "}
              </kbd>
            </div>
          </div>
          <div className="flex items-center justify-center p-10 text-center ">
            <h2 className="text-xl">
              How many licks does it take to get to the center of a Tootsie pop?
            </h2>
          </div>
        </div>
      </motion.div>
    </OverlayWrapper>
  );
}

Em seguida, passe essa ref e a função a ser chamada quando um clique fora desse elemento for detectado.

useClickOutside(searchboxRef, () => close(false));

Testá-lo agora dá o seguinte resultado:

Também podemos otimizar o código um pouco mais. Como fizemos com o recurso de acessibilidade, podemos tornar nosso listener para detectar atalhos muito mais limpo e eficiente com os seguintes passos.

Primeiro, crie um arquivo de hook useKeyBindings para lidar com combinações de teclas pressionadas.

Em seguida, defina o hook e a Interface. O hook aceitará um array de bindings, onde cada binding consiste em:

  • Um array de keys, que especifica a combinação de teclas (por exemplo, [“Control”, “k”])

  • Uma função de callback, que é chamada quando as teclas correspondentes são pressionadas.

import { useEffect } from "react";

// Defina a estrutura de um keybinding
interface KeyBinding {
  keys: string[]; // Array de teclas (por exemplo, ["Control", "k"])
  callback: () => void; // Função a ser executada quando as teclas são pressionadas
}

export const useKeyBindings = (bindings: KeyBinding[]) => {

};

Em seguida, crie a função handleKeyDown. Dentro do hook, defina uma função que irá escutar eventos de teclado. Essa função verificará se as teclas pressionadas correspondem a alguma combinação de teclas definida.

Normalizaremos as teclas para minúsculas para que a comparação não seja sensível a maiúsculas e minúsculas e rastrearemos quais teclas estão pressionadas verificando ctrlKey, shiftKey, altKey, metaKey e a tecla pressionada (por exemplo, “k” para Ctrl + K).

const handleKeyDown = (event: KeyboardEvent) => {
  // Rastrear as teclas que estão pressionadas
  const pressedKeys = new Set<string>();

  // Verificar teclas modificadoras (Ctrl, Shift, Alt, Meta)
  if (event.ctrlKey) pressedKeys.add("control");
  if (event.shiftKey) pressedKeys.add("shift");
  if (event.altKey) pressedKeys.add("alt");
  if (event.metaKey) pressedKeys.add("meta");

  // Adicionar a tecla que foi pressionada (por exemplo, "k" para Ctrl + K)
  if (event.key) pressedKeys.add(event.key.toLowerCase());
};

Em seguida, compararemos as teclas pressionadas com o array de teclas das nossas ligações para verificar se elas correspondem. Se corresponderem, chamaremos a função de callback associada. Também garantimos que o número de teclas pressionadas corresponda ao número de teclas definidas na ligação.

// Loop através de cada ligação de tecla
bindings.forEach(({ keys, callback }) => {
  // Normalizar as teclas para minúsculas para comparação
  const normalizedKeys = keys.map((key) => key.toLowerCase());

  // Verificar se as teclas pressionadas correspondem à ligação de tecla
  const isMatch =
    pressedKeys.size === normalizedKeys.length &&
    normalizedKeys.every((key) => pressedKeys.has(key));

  // Se as teclas corresponderem, chamar o callback
  if (isMatch) {
    event.preventDefault(); // Prevenir o comportamento padrão do navegador
    callback(); // Executar a função de callback
  }
});

Por fim, configure ouvintes de eventos no objeto window para escutar eventos de keydown. Esses ouvintes irão acionar a função handleKeyDown sempre que uma tecla for pressionada. Certifique-se de limpar os ouvintes de eventos quando o componente for desmontado.

useEffect(() => {
  // Adicione ouvintes de eventos para keydown
  window.addEventListener("keydown", handleKeyDown);

  // Limpeza dos ouvintes de eventos quando o componente for desmontado
  return () => {
    window.removeEventListener("keydown", handleKeyDown);
  };
}, [bindings]);

O hook completo useKeyBindings agora montado fica assim:

import { useEffect } from "react";

interface KeyBinding {
  keys: string[]; // Uma combinação de teclas para acionar o callback (por exemplo, ["Control", "k"])
  callback: () => void; // A função a ser executada quando as teclas forem pressionadas
}

export function useKeyBindings(bindings: KeyBinding[]) {
  useEffect(() => {
    const handleKeyDown = (event: KeyboardEvent) => {
      bindings.forEach(({ keys, callback }) => {
        const normalizedKeys = keys.map((key) => key.toLowerCase());
        const pressedKeys = new Set<string>();

        // Rastrear teclas modificadoras explicitamente
        if (event.ctrlKey) pressedKeys.add("control");
        if (event.shiftKey) pressedKeys.add("shift");
        if (event.altKey) pressedKeys.add("alt");
        if (event.metaKey) pressedKeys.add("meta");

        // Adicione a tecla realmente pressionada
        if (event.key) pressedKeys.add(event.key.toLowerCase());

        // Combinar exatamente: as teclas pressionadas devem corresponder às teclas definidas
        const isExactMatch =
          pressedKeys.size === normalizedKeys.length &&
          normalizedKeys.every((key) => pressedKeys.has(key));

        if (isExactMatch) {
          event.preventDefault(); // Impedir o comportamento padrão
          callback(); // Execute o callback
        }
      });
    };

    window.addEventListener("keydown", handleKeyDown);
    return () => {
      window.removeEventListener("keydown", handleKeyDown);
    };
  }, [bindings]);
}

Veja como você pode usar este hook no seu App:

import { useKeyBindings } from "./hooks/useKeyBindings";

export default function App() {
  const [isOpen, setIsOpen] = useState<boolean>(false);

  useKeyBindings([
    {
      keys: ["Control", "k"], // Escute por "Ctrl + K"
      callback: () => setIsOpen((prev) => !prev), // Alternar a caixa de busca
    },
    {
      keys: ["Escape"], // Escute por "Escape"
      callback: () => setIsOpen(false), // Fechar a caixa de busca
    },
  ]);

O que resulta no seguinte:

Com essa abordagem, você pode até adicionar vários atalhos para acionar a visibilidade do componente de busca.

useKeyBindings([
    {
      keys: ["Control", "k"], // Escute "Ctrl + K"
      callback: () => setIsOpen((prev) => !prev), // Alternar a caixa de busca
    },
    {
      keys: ["Control", "d"], // Escute "Ctrl + D"
      callback: () => setIsOpen((prev) => !prev), // Alternar a caixa de busca
    },
    {
      keys: ["Escape"], // Escute "Escape"
      callback: () => setIsOpen(false), // Fechar a caixa de busca
    },
  ]);

Aqui estão os links para todos os recursos que você pode precisar para este artigo:

Conclusão

Espero que este artigo tenha parecido um atalho bem cronometrado, levando você ao cerne da construção de componentes de atalho de teclado reutilizáveis. Com cada pressionamento de tecla e animação, você pode transformar experiências web comuns em extraordinárias.

Espero que seus atalhos ajudem você a criar aplicativos que se conectem com seus usuários. Afinal, as melhores jornadas frequentemente começam com a combinação certa.

Gosta dos meus artigos?

Sinta-se à vontade para me comprar um café aqui, para manter minha mente funcionando e fornecer mais artigos como este.

Informações de Contato

Quer se conectar ou me contatar? Sinta-se à vontade para me chamar nos seguintes: