당신이 저처럼 단축키를 사랑한다면, 몇 개의 키를 눌러 마법이 일어나는 것을 보는 것이 얼마나 만족스러운지 알 것입니다. 개발자들이 LLM과 코드 페이지에서 “코드를 빌리는” 데 사용하는 익숙한 Ctrl+C – Ctrl+V든, 우리가 좋아하는 도구에서 설정하는 개인화된 단축키든, 키보드 단축키는 시간을 절약해주고 우리를 컴퓨터 마법사처럼 느끼게 합니다.

걱정하지 마세요! 저는 키보드 단축키를 트리거하고 응답하는 컴포넌트를 구축하는 방법을 알아냈습니다. 이 기사에서는 React, Tailwind CSS, Framer Motion을 사용하여 이를 만드는 방법을 가르쳐 드리겠습니다.

목차

우리가 다룰 내용은 다음과 같습니다:

필수 조건

  • HTML, CSS, 그리고 Tailwind CSS 기초

  • JavaScript, React, 그리고 React Hooks 기초

키보드 바로 가기 리스너(KSL) 컴포넌트란 무엇인가요?

키보드 단축키 리스너 구성요소(KSLC)는 특정 키 조합을 감지하고 앱에서 작업을 트리거하는 구성요소입니다. 키보드 단축키에 응답하여 앱이 더 부드럽고 효율적인 사용자 경험을 제공하도록 설계되었습니다.

왜 중요한가요?

  • 접근성: KSL 구성요소를 사용하면 키보드를 사용하는 사람들이 작업을 트리거하기 쉬워져 앱이 보다 포괄적이고 사용하기 쉬워집니다.

  • 빠른 경험: 단축키는 빠르고 효율적이어서 사용자가 더 적은 시간에 작업을 완료할 수 있습니다. 더 이상 마우스를 찾아 헤맬 필요가 없습니다. 단순히 키(또는 두 개)를 누르면 작업이 실행됩니다!

  • 재사용성: KSL을 설정하면 앱 전체에서 다양한 단축키를 처리할 수 있어서 동일한 논리를 다시 작성하지 않고도 손쉽게 추가할 수 있습니다.

  • 더 깔끔한 코드: 키보드 이벤트 리스너를 여기저기에 흩뿌리는 대신, KSL 컴포넌트는 로직을 중앙 집중화하여 깔끔하게 유지합니다. 당신의 코드는 깔끔하고, 조직적이며, 유지보수가 쉬워집니다.

KSL 컴포넌트 만들기

작업을 빠르게 진행하기 위해 시작 파일이 포함된 GitHub 저장소를 준비했습니다. 이 리포를 클론하고 의존성을 설치하기만 하면 됩니다.

이번 프로젝트에서는 Tailwind의 홈페이지를 영감으로 삼아 KSL 기능을 구현하고 있습니다. 빌드 명령을 설치하고 실행한 후, 페이지는 다음과 같은 모습이어야 합니다:

리빌 컴포넌트 만들기

리빌 컴포넌트는 단축키를 사용할 때 보여주고 싶은 컴포넌트입니다.

우선, search-box.tsx라는 이름의 파일을 만들고 이 코드를 붙여넣으세요:

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

좋습니다, 이 코드에서 무슨 일이 일어나고 있나요?

  1. 메인 오버레이 (<div className="fixed top-0 left-0 ...">)

    • 이것은 배경을 어둡게 만드는 전체 화면 오버레이입니다.

    • backdrop-blur-sm은 배경에 미세한 블러 효과를 추가하고, bg-slate-900/50는 반투명한 어두운 오버레이를 제공합니다.

  2. 검색 박스 래퍼 (<div className="p-[15vh] ...">)

    • 내용은 패딩과 플렉스 유틸리티를 사용하여 중앙에 정렬됩니다.

    • max-w-xl은 검색 박스가 가독성을 위해 적절한 너비를 유지하도록 보장합니다.

그런 다음 App.tsx에서 해당 컴포넌트를 동적으로 표시하는 상태를 생성합니다:

const [isOpen, setIsOpen] = useState<boolean>(false);
  • useState: 이 훅은 isOpenfalse로 초기화하여 검색 박스가 기본적으로 숨겨져 있음을 나타냅니다.

  • isOpentrue로 설정되면, SearchBox 컴포넌트가 화면에 렌더링됩니다.

그리고 검색 컴포넌트를 렌더링합니다:

  {isOpen && <SearchBox />}

검색 컴포넌트를 보이게 하려면, 입력 버튼에 토글 기능을 추가하십시오:

<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>

onClick 이벤트는 isOpentrue로 설정하여 SearchBox를 표시합니다.

하지만 보셨듯이, 이는 클릭 동작에 의해 트리거되었고 키보드 단축키 동작이 아닙니다. 다음으로 이를 수행해 봅시다.

키보드 단축키로 컴포넌트 트리거하는 방법

리벨 컴포넌트를 열고 닫기 위해 키보드 단축키를 사용하려면, 특정 키 조합을 듣고 컴포넌트 상태를 업데이트하기 위해 useEffect 훅을 사용할 것입니다.

단계 1: 키보드 이벤트 청취

App.tsx 파일에 useEffect 훅을 추가하여 키 입력을 청취하십시오:

  useEffect(() => {
    const handleKeyDown = (event: KeyboardEvent) => {
      if (event.ctrlKey && event.key === Key.K) {
        event.preventDefault(); // 기본 브라우저 동작 방지

      }    };

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

이 코드에서 무슨 일이 일어나고 있나요?

  1. 효과 설정 (useEffect)

    • useEffect는 컴포넌트가 마운트될 때 키 입력 이벤트 청취기가 추가되고, 컴포넌트가 언마운트될 때 정리되어 메모리 누수를 방지합니다.
  2. 키 조합 (event.ctrlKey && event.key === "k")

    • event.ctrlKey컨트롤 키가 눌렸는지 확인합니다.

    • event.key === "k"는 “K” 키에 대해 구체적으로 청취하는 것을 보장합니다. 이를 통해 Ctrl + K 조합이 눌렸는지 확인합니다.

  3. 기본 동작 방지 (event.preventDefault())

    • 일부 브라우저는 Ctrl + K와 같은 키 조합에 기본 동작이 연결될 수 있습니다(예: 브라우저 주소 표시줄에 초점을 맞추는 것). preventDefault를 호출하면 이 동작을 중지시킵니다.
  4. 이벤트 정리 (return () => ...)

    • 정리 함수는 컴포넌트가 다시 렌더링될 때 중복 리스너가 추가되는 것을 방지하기 위해 이벤트 리스너를 제거합니다.

2단계: 컴포넌트 가시성 전환

다음으로, 단축키가 눌렸을 때 SearchBox의 가시성을 전환하도록 handleKeyDown 함수를 업데이트합니다:

useEffect(() => {
    const handleKeyDown = (event: KeyboardEvent) => {
      // Ctrl + K 리스닝
      if (event.ctrlKey && event.key === Key.K) {
        event.preventDefault(); // 기본 브라우저 동작 방지
        setIsOpen((prev) => !prev); // 검색 상자 전환
      } else if (event.key === Key.Escape) {
        setIsOpen(false); // 검색 상자 닫기
      }
    };

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

이 코드에서 무슨 일이 일어나고 있나요?

  1. 상태 전환 (setIsOpen((prev) => !prev))

    • Ctrl + K를 누르면 setIsOpen 상태 설정자가 SearchBox의 가시성을 전환합니다.

    • prev 인자는 이전 상태를 나타냅니다. !prev를 사용하면 값이 반전됩니다:

      • true (열림)은 false (닫힘)으로 변합니다.

      • false (닫힘)은 true (열림)으로 변합니다.

  2. Escape 키로 닫기 (event.key === "Escape")

    • Escape 키를 누르면 setIsOpen(false)가 명시적으로 상태를 false로 설정하여 SearchBox를 닫습니다.

This results in the following:

컴포넌트의 가시성 애니메이션

현재 우리의 컴포넌트는 작동하지만 약간의 화려함이 부족한 것 같지 않나요? 그것을 바꿔 봅시다.

단계 1: 오버레이 컴포넌트 생성

우리는 검색 상자를 위한 어두운 흐린 배경으로 작동하는 오버레이 컴포넌트를 생성하는 것으로 시작하겠습니다. 다음은 기본 버전입니다:

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

단계 2: 오버레이에 애니메이션 추가

이제 Framer Motion을 사용하여 오버레이를 페이드 인/아웃하도록 만들어 봅시다. OverlayWrapper 컴포넌트를 다음과 같이 업데이트하세요:

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>
  );
}
중요한 애니메이션 속성:
  • initial: 컴포넌트가 마운트될 때 시작 상태를 설정합니다 (완전 투명).

  • animate: 애니메이션할 상태를 정의합니다 (완전 불투명).

  • exit: 컴포넌트가 언마운트될 때 (페이드 아웃) 애니메이션을 지정합니다.

이제 검색 상자 자체에 약간의 움직임을 추가하세요. 나타날 때 슬라이드 및 페이드 인되고 사라질 때 슬라이드 아웃하도록 만들 것입니다.

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

단계 4: AnimatePresence로 애니메이션 추적 활성화

마지막으로, Framer Motion에서 제공하는 AnimatePresence 컴포넌트로 조건부 렌더링 로직을 감싸세요. 이를 통해 Framer Motion이 요소가 DOM에 들어오고 나갈 때 추적할 수 있습니다.

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

이를 통해 Framer Motion이 요소가 DOM에 들어오고 나갈 때 추적할 수 있습니다. 이를 통해 다음 결과를 얻을 수 있습니다:

아, 훨씬 나아졌네요!

KSL 컴포넌트 최적화 방법

우리가 끝냈다고 생각했다면, 조금 서둘지 말고요…할 일이 아직 조금 더 남았습니다.

접근성을 위해 최적화해야 합니다. 사용자가 검색 컴포넌트를 마우스로 닫을 수 있도록 추가해야 합니다. 접근성은 매우 중요합니다.

이를 위해 먼저 useClickOutside라는 훅을 생성하세요. 이 훅은 사용자가 대상 요소(검색 상자) 외부를 클릭할 때 모달을 닫는 매우 인기 있는 동작을 알기 위해 참조 요소를 사용합니다.


import { useEffect } from "react";

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

export const useClickOutside = (
  ref: React.RefObject<HTMLElement>,
  handler: ClickOutsideHandler
) => {
  useEffect(() => {
    const listener = (event: Event) => {
      // 참조 요소나 하위 요소를 클릭할 경우 아무것도 수행하지 않습니다.
      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]);
};

이 훅을 사용하려면 검색 컴포넌트를 열고 닫는 함수를 전달하세요:

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

그럼 적절한 prop 유형으로 검색 함수를 받으세요.

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

그 후에 추적하려는 항목에 참조(ref)를 생성하고 해당 요소를 표시하세요.

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

그런 다음 해당 ref 및 해당 요소 외부의 클릭이 감지될 때 호출할 함수를 전달하세요.

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

지금 시험해보면 다음과 같은 결과가 나옵니다.

코드를 조금 더 최적화할 수도 있습니다. 접근성 기능과 마찬가지로 바로 가기 키를 감지하는 청취기를 다음 단계로 더 깔끔하고 효율적으로 만들 수 있습니다.

먼저, 키 조합을 처리하는 useKeyBindings 훅 파일을 만드세요.

그런 다음 훅과 인터페이스를 정의하세요. 훅은 각 바인딩이 다음으로 구성된 배열을 수락할 것입니다.

  • 키 조합을 지정하는 keys 배열(예: [“Control”, “k”])

  • 해당 키가 눌릴 때 호출되는 콜백 함수

import { useEffect } from "react";

// 키바인딩의 구조 정의
interface KeyBinding {
  keys: string[]; // 키 배열 (예: ["Control", "k"])
  callback: () => void; // 키가 눌릴 때 실행할 함수
}

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

};

다음으로, handleKeyDown 함수를 생성하세요. 훅 안에서, 키보드 이벤트를 감지할 함수를 정의합니다. 이 함수는 눌린 키가 정의된 키 조합과 일치하는지 확인할 것입니다.

비교가 대소문자를 구분하지 않도록 키를 소문자로 표준화하고, ctrlKey, shiftKey, altKey, metaKey 및 눌린 키를 확인하여 눌린 키를 추적할 것입니다(예: Ctrl + K의 경우 “k”).

const handleKeyDown = (event: KeyboardEvent) => {
  // 눌린 키를 추적합니다
  const pressedKeys = new Set<string>();

  // 수정자 키(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");

  // 눌린 키를 추가합니다 (예: Ctrl + K의 경우 "k")
  if (event.key) pressedKeys.add(event.key.toLowerCase());
};

다음으로, 눌린 키를 바인딩의 키 배열과 비교하여 일치하는지 확인합니다. 일치하는 경우 연결된 콜백 함수를 호출할 것입니다. 또한, 눌린 키의 수가 바인딩에서 정의된 키의 수와 일치하는지 확인합니다.

// 각 키 바인딩을 순회합니다
bindings.forEach(({ keys, callback }) => {
  // 비교를 위해 키를 소문자로 표준화합니다
  const normalizedKeys = keys.map((key) => key.toLowerCase());

  // 눌린 키가 키 바인딩과 일치하는지 확인합니다
  const isMatch =
    pressedKeys.size === normalizedKeys.length &&
    normalizedKeys.every((key) => pressedKeys.has(key));

  // 키가 일치하는 경우, 콜백을 호출합니다
  if (isMatch) {
    event.preventDefault(); // 기본 브라우저 동작 방지
    callback(); // 콜백 함수 실행
  }
});

마지막으로, 키다운 이벤트를 수신하기 위해 윈도우 객체에 이벤트 리스너를 설정합니다. 이러한 리스너는 키가 눌릴 때마다 handleKeyDown 함수를 트리거합니다. 컴포넌트가 언마운트될 때 이벤트 리스너를 정리하는 것을 잊지 마세요.

useEffect(() => {
  // 키다운 이벤트 리스너 추가
  window.addEventListener("keydown", handleKeyDown);

  // 컴포넌트가 언마운트될 때 이벤트 리스너 정리
  return () => {
    window.removeEventListener("keydown", handleKeyDown);
  };
}, [bindings]);

전체 useKeyBindings 훅을 종합하면 다음과 같습니다:

import { useEffect } from "react";

interface KeyBinding {
  keys: string[]; // 콜백을 트리거하기 위한 키 조합 (예: ["Control", "k"])
  callback: () => void; // 키가 눌릴 때 실행할 함수
}

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

        // 수정자 키를 명시적으로 추적
        if (event.ctrlKey) pressedKeys.add("control");
        if (event.shiftKey) pressedKeys.add("shift");
        if (event.altKey) pressedKeys.add("alt");
        if (event.metaKey) pressedKeys.add("meta");

        // 실제로 눌린 키 추가
        if (event.key) pressedKeys.add(event.key.toLowerCase());

        // 정확히 일치: 눌린 키는 정의된 키와 일치해야 함
        const isExactMatch =
          pressedKeys.size === normalizedKeys.length &&
          normalizedKeys.every((key) => pressedKeys.has(key));

        if (isExactMatch) {
          event.preventDefault(); // 기본 동작 방지
          callback(); // 콜백 실행
        }
      });
    };

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

이 훅을 App에서 사용하는 방법은 다음과 같습니다:

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

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

  useKeyBindings([
    {
      keys: ["Control", "k"], // "Ctrl + K" 수신
      callback: () => setIsOpen((prev) => !prev), // 검색 상자 토글
    },
    {
      keys: ["Escape"], // "Escape" 수신
      callback: () => setIsOpen(false), // 검색 상자 닫기
    },
  ]);

다음과 같은 결과를 제공합니다:

이 접근 방식을 사용하면 검색 컴포넌트의 가시성을 트리거하는 여러 단축키를 추가할 수 있습니다.

useKeyBindings([
    {
      keys: ["Control", "k"], // "Ctrl + K"를 듣기
      callback: () => setIsOpen((prev) => !prev), // 검색 상자 전환
    },
    {
      keys: ["Control", "d"], // "Ctrl + D"를 듣기
      callback: () => setIsOpen((prev) => !prev), // 검색 상자 전환
    },
    {
      keys: ["Escape"], // "Escape"를 듣기
      callback: () => setIsOpen(false), // 검색 상자 닫기
    },
  ]);

이 기사를 위해 필요한 모든 리소스에 대한 링크는 다음과 같습니다:

결론

이 기사가 재사용 가능한 키보드 단축키 컴포넌트를 만드는 핵심으로 안내하는 잘 맞은 단축키처럼 느껴지길 바랍니다. 모든 키 입력과 애니메이션을 통해 이제 평범한 웹 경험을 특별한 경험으로 전환할 수 있습니다.

당신의 단축키가 사용자와 잘 맞는 앱을 만드는 데 도움이 되기를 바랍니다. 결국, 최고의 여정은 종종 올바른 조합으로 시작됩니다.

제 글을 좋아하셨나요?

제 두뇌를 활발히 유지하고 이와 같은 글을 더 제공하려면 여기에서 커피를 사주세요.

연락처

연락하거나 연결하고 싶으신가요? 아래 정보로 연락해 주세요: