Se você é como eu e adora atalhos, sabe como é 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 expert em tecnologia.

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.

Índice

Aqui está tudo o que vamos cobrir:

Pré-requisitos

  • Fundamentos de HTML, CSS e Tailwind CSS

  • Fundamentos de JavaScript, React e React Hooks.

O que é um Componente Listener de Atalho de Teclado (KSL)?

Um componente Listener de Atalhos de Teclado (KSLC) é um componente que fica atento a combinações de teclas específicas e aciona ações em seu aplicativo. Ele é projetado para fazer com que seu aplicativo responda a atalhos de teclado, permitindo uma experiência do usuário mais suave e eficiente.

Por que é importante?

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

  • Experiência mais ágil: Atalhos são rápidos e eficientes, permitindo que os usuários realizem tarefas em menos tempo. Não é mais preciso procurar o mouse por aí – 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 seu aplicativo, facilitando a adição 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 ao centralizar a lógica. Seu código permanece limpo, organizado e mais fácil de manter.

Como Criar o Componente KSL

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

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

Como Criar o Componente Reveal

O componente reveal é o componente que queremos mostrar quando usamos 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 fundo.

    • O backdrop-blur-sm adiciona um desfoque sutil ao fundo, e bg-slate-900/50 proporciona uma sobreposição escura semi-transparente.

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

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

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

Em seu App.tsx, crie um estado que mostre esse componente dinamicamente:

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 uma ação de 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 o 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 é montado e limpo quando o componente é desmontado, 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”. Juntos, 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ço do navegador). Chamar preventDefault impede esse comportamento.
  4. Limpeza de Evento (return () => ...)

    • A função de limpeza remove o ouvinte de evento para evitar que ouvintes duplicados sejam adicionados caso o componente seja renderizado novamente.

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 setter de estado setIsOpen alterna a visibilidade do SearchBox.

    • O argumento prev representa o estado anterior. Usando !prev, seu valor é invertido:

      • 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, você não acha? Vamos mudar isso.

Passo 1: Criar o Componente de Sobreposição

Começaremos criando um componente de sobreposição, que atua como o fundo escuro e desfocado para a caixa de pesquisa. Aqui está a versão básica:

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 à Sobreposição

Agora, vamos fazer a sobreposição desvanecer para dentro e para fora 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>
  );
}
Propriedades principais de animação:
  • initial: Define o estado inicial quando o componente é montado (totalmente transparente).

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

  • exit: Especifica a animação quando o componente é desmontado (desaparecendo).

Em seguida, adicione um pouco de movimento à própria caixa de pesquisa. Vamos fazer com que ela deslize e desapareça quando aparecer e deslize para fora quando 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>
  );
}

Etapa 4: Ative o Rastreamento de Animações 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ê pensou que havíamos 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. Este hook usa um elemento de referência para saber quando um usuário está clicando fora do elemento alvo (caixa de pesquisa), 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 faça nada se clicar no elemento da 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 este 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 o 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 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));

Testando agora, o resultado é o seguinte.

Também podemos otimizar um pouco mais o código. Assim como fizemos com o recurso de acessibilidade, podemos tornar nosso ouvinte de detecção de atalhos mais limpo e eficiente com os seguintes passos.

Primeiro, crie um arquivo de gancho useKeyBindings para lidar com combinações de pressionamento de tecla.

Em seguida, defina o gancho e a Interface. O gancho aceitará um array de vinculações, onde cada vinculação consiste em:

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

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

import { useEffect } from "react";

// Definir a estrutura de uma vinculação de teclas
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.

Vamos normalizar as teclas para minúsculas para que a comparação não seja sensível a maiúsculas e minúsculas e rastrear 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 as 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, vamos comparar as teclas pressionadas com o array de teclas de nossos bindings para verificar se 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 no binding.

// Percorrer cada keybinding
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 ao keybinding
  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
  }
});

Finalmente, 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 são 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>();

        // Rastreie as 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 pressionada real
        if (event.key) pressedKeys.add(event.key.toLowerCase());

        // Correspondência exata: 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 em 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 pesquisa
    },
    {
      keys: ["Escape"], // Escute por "Escape"
      callback: () => setIsOpen(false), // Fechar a caixa de pesquisa
    },
  ]);

O que resulta no seguinte:

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

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

Aqui estão 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 reutilizáveis de atalhos de teclado. Com cada pressionamento de tecla e animação, você agora pode transformar experiências web comuns em experiências extraordinárias.

Espero que seus atalhos ajudem você a criar aplicativos que tenham sintonia com seus usuários. Afinal, as melhores jornadas muitas vezes começam com a combinação certa.

Gosta dos meus artigos?

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

Informações de Contato

Quer se conectar ou entrar em contato comigo? Sinta-se à vontade para me chamar nos seguintes: