もしあなたが私のようにショートカットが大好きなら、いくつかのキーを押して魔法が起こるのを見ることがどれほど満足感を与えるかを知っているでしょう。開発者がLLMやコードページから「コードを借りる」ために使うお馴染みのCtrl+C – Ctrl+Vや、私たちが好きなツールで設定するパーソナライズされたショートカットに関係なく、キーボードショートカットは時間を節約し、コンピュータの達人のように感じさせてくれます。

さて、心配しないでください!私はキーボードショートカットをトリガーし、応答するコンポーネントを構築するためのコードを解読しました。この記事では、React、Tailwind CSS、Framer Motionを使ってそれらを作成する方法を教えます。

目次

ここでは、私たちがカバーするすべての内容を紹介します:

前提条件

  • HTML、CSS、およびTailwind CSSの基本

  • JavaScript、React、およびReact Hooksの基本。

キーボードショートカットリスナー(KSL)コンポーネントとは?

キーボードショートカットリスナーコンポーネント(KSLC)は特定のキーコンビネーションを監視し、アプリ内でアクションをトリガーするコンポーネントです。キーボードショートカットに応答するように設計されており、よりスムーズで効率的なユーザーエクスペリエンスを提供します。

なぜ重要なのか?

  • アクセシビリティ:KSLコンポーネントは、キーボードを使用する人々がアクションをトリガーしやすくするため、アプリをより包括的で使いやすくします。

  • 迅速な体験:ショートカットは迅速かつ効率的であり、ユーザーが時間を節約して作業を行えます。もはやマウスを探す必要はありません—キー(または2つ)を押すだけで、アクションが実行されます!

  • 再利用性: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は、Controlキーが押されているかどうかを確認します。

    • 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. エスケープキーでのクローズ(event.key === "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 でアニメーションの追跡を有効にする

最後に、条件付きレンダリングロジックを AnimatePresence コンポーネントでラップします。これは Framer Motion が要素が DOM に入ったり出たりするのを追跡できるようにします。

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

これにより、Framer Motion が要素が DOM に入ったり出たりするのを追跡できるようになります。これで、次のような結果が得られます:

ああ、ずっと良くなりました!

KSL コンポーネントを最適化する方法

もう終わったと思ったら、まだ少しやることがあります…

アクセシビリティの最適化が必要です。ユーザーがマウスで検索コンポーネントを閉じる方法を追加する必要があります。アクセシビリティは非常に重要です。

これを行うには、useClickOutside というフックを作成します。このフックは、参照要素を使用して、ユーザーがターゲット要素(検索ボックス)の外をクリックしているときに知ることができるので、モーダルや KSL コンポーネントを閉じるための非常に一般的な動作です。


import { useEffect } from "react";

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

export const useClickOutside = (
  ref: React.RefObject<HTMLElement>,
  handler: ClickOutsideHandler
) => {
  useEffect(() => {
    const listener = (event: Event) => {
      // ref の要素またはその子要素をクリックした場合は何もしない
      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関数を作成します。フックの内部で、キーボードイベントをリッスンする関数を定義します。この関数は、押されたキーが定義されたキーの組み合わせと一致するかどうかをチェックします。

キーを小文字に正規化して、大文字小文字を区別しない比較を行い、ctrlKeyshiftKeyaltKeymetaKey、および押されたキー(例えば、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(); // コールバック関数を実行する
  }
});

最後に、ウィンドウオブジェクト上にイベントリスナーを設定して、keydownイベントを監視します。これらのリスナーは、キーが押されるたびにhandleKeyDown関数をトリガーします。コンポーネントがアンマウントされる際にイベントリスナーをきれいに削除することを忘れないでください。

useEffect(() => {
  // keydownのイベントリスナーを追加
  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), // 検索ボックスを閉じる
    },
  ]);

この記事で必要なすべてのリソースへのリンクはこちらです:

結論

この記事が、再利用可能なキーボードショートカットコンポーネントの構築の核心に迅速に誘導するようなタイミングが良いショートカットのように感じられることを願っています。各キープレスとアニメーションにより、普通のウェブ体験を非凡なものに変えることができます。

ショートカットがユーザーとクリックするアプリを作成するのに役立つことを願っています。結局のところ、最高の旅はしばしばちょうど適切な組み合わせから始まります。

私の記事が気に入りましたか?

ぜひ、こちらでコーヒーを買ってください、私の頭を活性化させて、こうした記事をもっと提供できるようにしてください。

連絡先情報

つながりたい、または連絡したいですか?以下の方法でお気軽にご連絡ください: