Si eres como yo y amas los atajos, sabes lo satisfactorio que es presionar unas pocas teclas y ver la magia suceder. Ya sea el familiar Ctrl+C – Ctrl+V que los desarrolladores utilizan para “tomar prestado código” 😉 de modelos de lenguaje y páginas de código, o los atajos personalizados que configuramos en nuestras herramientas favoritas, los atajos de teclado ahorran tiempo y nos hacen sentir como unos genios de la informática.

¡No temas! He descifrado el código para construir componentes que activan y responden a los atajos de teclado. En este artículo, te enseñaré cómo crearlos con React, Tailwind CSS y Framer Motion.

Tabla de Contenido

Aquí tienes todo lo que cubriremos:

Prerrequisitos

  • Fundamentos de HTML, CSS y Tailwind CSS

  • Fundamentos de JavaScript, React y React Hooks.

¿Qué es un componente Listener de atajos de teclado (KSL)?

Un componente de escucha de atajos de teclado (KSLC) es un componente que escucha combinaciones de teclas específicas y activa acciones en tu aplicación. Está diseñado para hacer que tu aplicación responda a atajos de teclado, permitiendo una experiencia de usuario más fluida y eficiente.

¿Por qué es importante?

  • Accesibilidad: El componente KSL facilita que las personas que utilizan un teclado activen acciones, haciendo que tu aplicación sea más inclusiva y fácil de usar.

  • Experiencia más ágil: Los atajos son rápidos y eficientes, permitiendo a los usuarios completar tareas en menos tiempo. ¡No más buscar el ratón—simplemente presiona una tecla (o dos) y boom, la acción ocurre!

  • Reutilización: Una vez que hayas configurado tu KSL, puede manejar diferentes atajos a través de tu aplicación, lo que facilita la adición sin reescribir la misma lógica.

  • Código más limpio: En lugar de dispersar oyentes de eventos de teclado por todas partes, el componente KSL mantiene las cosas ordenadas al centralizar la lógica. Tu código se mantiene limpio, organizado y más fácil de mantener.

Cómo construir el componente KSL

He preparado un repositorio de GitHub con archivos de inicio para acelerar las cosas. Simplemente clona este repositorio e instala las dependencias.

Para este proyecto, estamos utilizando la página de inicio de Tailwind como nuestra musa y creando la funcionalidad KSL. Después de instalar y ejecutar el comando de construcción, así es como debería verse tu página:

Cómo crear el componente Reveal

El componente reveal es el componente que queremos mostrar cuando usamos el atajo.

Para comenzar, crea un archivo llamado search-box.tsx y pega 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, ¿qué está pasando en este código?

  1. Superposición principal (<div className="fixed top-0 left-0 ...">)

    • Esta es la superposición de pantalla completa que oscurece el fondo.

    • El backdrop-blur-sm agrega un ligero desenfoque al fondo, y bg-slate-900/50 le da una superposición oscura semi-transparente.

  2. Contenedor del Cuadro de Búsqueda (<div className="p-[15vh] ...">)

    • El contenido está centrado utilizando padding y utilidades flex.

    • El max-w-xl asegura que el cuadro de búsqueda se mantenga dentro de un ancho razonable para la legibilidad.

Luego, en tu App.tsx, crea un estado que muestre dinámicamente ese componente:

const [isOpen, setIsOpen] = useState<boolean>(false);
  • useState: Este hook inicializa isOpen en false, lo que significa que el cuadro de búsqueda está oculto por defecto.

  • Cuando isOpen se establece en true, el componente SearchBox se renderizará en la pantalla.

Y renderiza el componente de búsqueda:

  {isOpen && <SearchBox />}

Para mostrar el componente de búsqueda, agrega una función de alternancia al botón 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>

El evento onClick establece isOpen en true, mostrando el SearchBox.

Pero como has visto, esto fue desencadenado por una acción de clic, no por una acción de atajo de teclado. Haremos eso a continuación.

Cómo activar el componente mediante atajo de teclado

Para hacer que el componente de revelado se abra y cierre utilizando un atajo de teclado, usaremos un hook useEffect para escuchar combinaciones de teclas específicas y actualizar el estado del componente en consecuencia.

Paso 1: Escuchar eventos de teclado

Agrega un hook useEffect en tu archivo App.tsx para escuchar pulsaciones de teclas:

  useEffect(() => {
    const handleKeyDown = (event: KeyboardEvent) => {
      if (event.ctrlKey && event.key === Key.K) {
        event.preventDefault(); // Prevenir el comportamiento predeterminado del navegador

      }    };

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

¿Qué está sucediendo en este código?

  1. Configuración del efecto (useEffect)

    • useEffect asegura que el escuchador de eventos para las pulsaciones de teclas se agregue cuando el componente se monta y se limpie cuando el componente se desmonta, previniendo fugas de memoria.
  2. Combinación de Teclas (event.ctrlKey && event.key === "k")

    • El event.ctrlKey verifica si la tecla Control está siendo presionada.

    • El event.key === "k" asegura que estamos escuchando específicamente la tecla “K”. Juntos, esto verifica si se presiona la combinación Ctrl + K.

  3. Prevenir Comportamiento Predeterminado (event.preventDefault())

    • Algunos navegadores pueden tener comportamientos predeterminados vinculados a combinaciones de teclas como Ctrl + K (por ejemplo, enfocar la barra de direcciones del navegador). Llamar a preventDefault detiene este comportamiento.
  4. Limpieza de Eventos (return () => ...)

    • La función de limpieza elimina el escuchador de eventos para evitar que se agreguen escuchadores duplicados si el componente se vuelve a renderizar.

Paso 2: Alternar la Visibilidad del Componente

Luego, actualiza la función handleKeyDown para alternar la visibilidad de SearchBox cuando se presiona el atajo:

useEffect(() => {
    const handleKeyDown = (event: KeyboardEvent) => {
      // Escuchar Ctrl + K
      if (event.ctrlKey && event.key === Key.K) {
        event.preventDefault(); // Prevenir el comportamiento por defecto del navegador
        setIsOpen((prev) => !prev); // Alternar la caja de búsqueda
      } else if (event.key === Key.Escape) {
        setIsOpen(false); // Cerrar la caja de búsqueda
      }
    };

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

¿Qué está sucediendo en este código?

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

    • Cuando se presiona Ctrl + K, el setter de estado setIsOpen alterna la visibilidad del SearchBox.

    • El argumento prev representa el estado anterior. Usar !prev invierte su valor:

      • true (abierto) se convierte en false (cerrar).

      • false (cerrado) se convierte en true (abierto).

  2. Cerrar con la tecla Escape (event.key === "Escape")

    • Cuando se presiona la tecla Escape, setIsOpen(false) establece explícitamente el estado en false, cerrando el SearchBox.

Esto resulta en lo siguiente:

Cómo animar la visibilidad del componente

Por el momento, nuestro componente funciona, pero le falta un poco de estilo, ¿no crees? Vamos a cambiar eso.

Paso 1: Crear el Componente de Superposición

Comenzaremos creando un componente de superposición, que actúa como el fondo oscuro y difuminado para el cuadro de búsqueda. Aquí está la versión 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>
  );
}

Paso 2: Agregar Animaciones a la Superposición

Ahora, hagamos que la superposición se desvanezca usando Framer Motion. Actualiza el componente OverlayWrapper de esta manera:

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>
  );
}
Propiedades clave de animación:
  • initial: Establece el estado inicial cuando el componente se monta (totalmente transparente).

  • animate: Define el estado hacia el cual se animará (totalmente opaco).

  • exit: Especifica la animación cuando el componente se desmonta (desvanecerse).

Siguiente, añade algo de movimiento al cuadro de búsqueda en sí. Haremos que se deslice y desvanezca al aparecer y que se deslice al salir.

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>
  );
}

Paso 4: Habilita el Seguimiento de Animaciones con AnimatePresence

Finalmente, envuelve tu lógica de renderizado condicional en el componente AnimatePresence proporcionado por Framer Motion. Esto asegura que Framer Motion rastree cuándo los elementos entran y salen del DOM.

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

Esto permite que Framer Motion rastree cuándo un elemento entra y sale del DOM. Con esto, obtenemos el siguiente resultado:

¡Ah, mucho mejor!

Cómo Optimizar Tu Componente KSL

Si pensabas que habíamos terminado, no tan rápido… Aún nos queda un poco más por hacer.

Necesitamos optimizar para accesibilidad. Deberíamos añadir una forma para que los usuarios cierren el componente de búsqueda con el ratón, ya que la accesibilidad es muy importante.

Para hacer esto, comienza creando un hook llamado useClickOutside. Este hook utiliza un elemento de referencia para saber cuándo un usuario está haciendo clic fuera del elemento objetivo (cuadro de búsqueda), que es un comportamiento muy popular para cerrar modales y KSLCs.


import { useEffect } from "react";

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

export const useClickOutside = (
  ref: React.RefObject<HTMLElement>,
  handler: ClickOutsideHandler
) => {
  useEffect(() => {
    const listener = (event: Event) => {
      // No hacer nada si se hace clic en el elemento de referencia o elementos descendientes
      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, pasa la función responsable de abrir y cerrar el componente de búsqueda:

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

Luego recibe la función en la búsqueda con su tipo de propiedad adecuado:

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

Después de eso, crea una referencia (ref) al elemento que deseas rastrear y marca ese 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>
  );
}

Luego pasa esa ref y la función que se llamará cuando se detecte un clic fuera de ese elemento.

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

Probarlo ahora da el siguiente resultado:

También podemos optimizar un poco más el código. Al igual que hicimos con la función de accesibilidad, podemos hacer que nuestro listener para detectar atajos sea mucho más limpio y eficiente con los siguientes pasos.

Primero, crea un archivo de hook useKeyBindings para manejar combinaciones de teclas.

Luego define el hook y la interfaz. El hook aceptará un array de bindings, donde cada binding consiste en:

  • Un array keys, que especifica la combinación de teclas (por ejemplo, [“Control”, “k”])

  • Una función de callback, que se llama cuando se presionan las teclas correspondientes.

import { useEffect } from "react";

// Define la estructura de un keybinding
interface KeyBinding {
  keys: string[]; // Array de teclas (por ejemplo, ["Control", "k"])
  callback: () => void; // Función a ejecutar cuando se presionan las teclas
}

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

};

A continuación, crea la función handleKeyDown. Dentro del hook, define una función que escuche los eventos del teclado. Esta función verificará si las teclas presionadas coinciden con alguna combinación de teclas definida.

Normalizaremos las teclas a minúsculas para que la comparación no distinga entre mayúsculas y minúsculas y rastrearemos qué teclas están presionadas verificando ctrlKey, shiftKey, altKey, metaKey, y la tecla presionada (por ejemplo, “k” para Ctrl + K).

const handleKeyDown = (event: KeyboardEvent) => {
  // Rastrear las teclas que están presionadas
  const pressedKeys = new Set<string>();

  // Verificar las 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");

  // Agregar la tecla que fue presionada (por ejemplo, "k" para Ctrl + K)
  if (event.key) pressedKeys.add(event.key.toLowerCase());
};

Luego, compararemos las teclas presionadas con el array de teclas de nuestras vinculaciones para verificar si coinciden. Si lo hacen, llamaremos a la función de callback asociada. También nos aseguramos de que el número de teclas presionadas coincida con el número de teclas definidas en la vinculación.

// Iterar a través de cada enlace de teclas
bindings.forEach(({ keys, callback }) => {
  // Normalizar las teclas a minúsculas para la comparación
  const normalizedKeys = keys.map((key) => key.toLowerCase());

  // Verificar si las teclas presionadas coinciden con el enlace de teclas
  const isMatch =
    pressedKeys.size === normalizedKeys.length &&
    normalizedKeys.every((key) => pressedKeys.has(key));

  // Si las teclas coinciden, llamar al callback
  if (isMatch) {
    event.preventDefault(); // Prevenir el comportamiento predeterminado del navegador
    callback(); // Ejecutar la función de callback
  }
});

Finalmente, configura oyentes de eventos en el objeto window para escuchar eventos de keydown. Estos oyentes activarán la función handleKeyDown cada vez que se presione una tecla. Asegúrate de limpiar los oyentes de eventos cuando el componente se desmonte.

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

  // Limpiar los oyentes de eventos cuando el componente se desmonte
  return () => {
    window.removeEventListener("keydown", handleKeyDown);
  };
}, [bindings]);

El hook completo useKeyBindings ahora se ve así:

import { useEffect } from "react";

interface KeyBinding {
  keys: string[]; // Una combinación de teclas para activar la devolución de llamada (por ejemplo, ["Control", "k"])
  callback: () => void; // La función a ejecutar cuando se presionan las teclas
}

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 explícitamente
        if (event.ctrlKey) pressedKeys.add("control");
        if (event.shiftKey) pressedKeys.add("shift");
        if (event.altKey) pressedKeys.add("alt");
        if (event.metaKey) pressedKeys.add("meta");

        // Agregar la tecla que se presionó realmente
        if (event.key) pressedKeys.add(event.key.toLowerCase());

        // Coincidir exactamente: las teclas presionadas deben coincidir con las teclas definidas
        const isExactMatch =
          pressedKeys.size === normalizedKeys.length &&
          normalizedKeys.every((key) => pressedKeys.has(key));

        if (isExactMatch) {
          event.preventDefault(); // Prevenir el comportamiento predeterminado
          callback(); // Ejecutar la devolución de llamada
        }
      });
    };

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

Así es como puedes usar este hook en tu App:

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

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

  useKeyBindings([
    {
      keys: ["Control", "k"], // Escuchar "Ctrl + K"
      callback: () => setIsOpen((prev) => !prev), // Alternar el cuadro de búsqueda
    },
    {
      keys: ["Escape"], // Escuchar "Escape"
      callback: () => setIsOpen(false), // Cerrar el cuadro de búsqueda
    },
  ]);

Lo que da el siguiente resultado:

Con este enfoque, incluso puedes agregar múltiples accesos directos para activar la visibilidad del componente de búsqueda.

useKeyBindings([
    {
      keys: ["Control", "k"], // Escuchar "Ctrl + K"
      callback: () => setIsOpen((prev) => !prev), // Alternar la caja de búsqueda
    },
    {
      keys: ["Control", "d"], // Escuchar "Ctrl + D"
      callback: () => setIsOpen((prev) => !prev), // Alternar la caja de búsqueda
    },
    {
      keys: ["Escape"], // Escuchar "Escape"
      callback: () => setIsOpen(false), // Cerrar la caja de búsqueda
    },
  ]);

Aquí tienes enlaces a todos los recursos que puedas necesitar para este artículo:

Conclusión

Espero que este artículo haya sido como un atajo bien programado, llevándote al corazón de la creación de componentes de atajos de teclado reutilizables. Con cada pulsación de tecla y animación, ahora puedes convertir experiencias web ordinarias en extraordinarias.

Espero que tus atajos te ayuden a crear aplicaciones que conecten con tus usuarios. Después de todo, los mejores viajes a menudo comienzan con la combinación justa.

¿Te gustan mis artículos?

Si deseas, cómprame un café aquí, para mantener mi mente activa y poder ofrecerte más artículos como este.

Información de contacto

¿Quieres conectarte conmigo o contactarme? No dudes en encontrarme en los siguientes lugares: