BLK-85Blocks

Command Palette (⌘K site search)

A site-search entry point that opens with ⌘K (Ctrl+K on Windows) and narrows pages, actions and frequently visited items as you type. Matching is fuzzy: an item stays in the list if the typed characters appear in its name in order, consecutive and word-start matches rank higher, and matched characters are underlined in amber. Both sides are normalised first (NFKC, lower case, katakana to hiragana), and every item carries readings, romanisation and English aliases, so unconverted IME hiragana or words like "price" and "faq" also hit; such rows show the matching reading or alias with the same highlighting, so it is clear why they appeared. With an empty field the three most recently used items form the first section, and they keep a score bonus once you start typing. The input is a combobox: arrow keys select, Enter runs, and Escape first clears the text, then closes and returns focus to the trigger. Enter pressed to confirm IME composition is ignored. In the specimen the panel opens inside a mock site frame and the result is announced through aria-live. Opening and closing use only opacity and translateY; with prefers-reduced-motion the transitions are off and the open panel reads as a still image. No dependencies, no rounded corners, no small English kickers or trailing arrow links, just hairline rules and whitespace.

Added:
2026-09-30
Dependencies:
None
tags
#command-palette #search #combobox #keyboard #fuzzy-search #navigation #accessibility #ui #no-image

Preview

キーを2つ押せば、どのページへも

⌘K(Windows は Ctrl+K)で検索窓が開きます。ページ名や操作名の一部を打つと候補が絞れ、読みがなや英語名でも当たります。直前に使った項目は先頭に残ります。

First CH

/

トップ

候補の並べ方

  1. 1打った字が順番どおり含まれる間に別の字が挟まっても候補に残します。「さよう」で採用情報に当たります。
  2. 2続けて一致・語頭で一致なら上へ一致した字は下線で示します。飛ばした字が多いほど少し下がります。
  3. 3読みがな・英語名でも探す変換前のひらがなや、price・faq のような英語名でも当たります。
  4. 4最近使った項目を先頭に空欄のときは直近3件を最初に並べ、打ち始めてからも少し上へ寄せます。
⌘K
開く・閉じる(Ctrl+K)
↑ ↓
候補を選ぶ
Enter
選んだ項目を開く
Esc
文字を消す → 閉じる
"use client";

import { useEffect, useId, useMemo, useRef, useState, type KeyboardEvent } from "react";
import { Montserrat } from "next/font/google";

const montserrat = Montserrat({ weight: ["500", "600"], subsets: ["latin"], display: "swap" });

/**
 * コマンドパレット(⌘K で開くサイト内検索)
 *
 * 差別化: 既存のサイト索引は行き先を円周に並べて回す「位置で選ぶ」型、検索ゼロの画面は結果が無いときの
 * 後始末だった。本ブロックは打鍵で絞り込む入口そのもの(あいまい一致の採点・一致した字の強調・
 * 最近使った項目の先頭固定)で、開閉から実行までキーボードだけで完結する。角丸・英字の小見出し・末尾の矢印リンクは置かない。
 *
 * 汎用技法メモ:
 * 1. **あいまい一致は「順に含まれるか」+加点**。打った字が項目名に順番どおり含まれれば候補にし、
 *    連続して一致(+3)・語頭で一致(+2)・先頭から一致(+4)で加点、飛ばした字数だけ少し減点する。
 *    1文字目の出現位置ごとに貪欲に照合して最高点を採れば、短い項目名なら全探索でも十分速い。
 * 2. **比較の前に正規化する**。NFKC(全角英数→半角)→ 小文字化 → カタカナをひらがなへ(U+30A1〜U+30F6 から 0x60 引く)。
 *    項目には読みがな・ローマ字・英語名を keywords で持たせると、IME の変換前(ひらがな)のまま候補が絞れる。
 *    keywords で当たったときは、当たった読み・別名そのものを行に添えて同じように字を強調する(なぜ出たかが見える)。
 * 3. **最近使った項目は二重に扱う**。空欄のときは先頭の節として並べ、打ち始めたら同じ項目に加点(+4)して
 *    同点付近で上に来るようにする。完全に固定すると打った語と関係ない行が居座るため、点数で寄せるだけにする。
 * 4. **入力欄はフォーカスを離さない combobox**。候補は aria-activedescendant で指し、矢印キーで選ぶ。
 *    Enter は IME 変換中(isComposing / keyCode 229)なら無視する(変換の確定で誤実行しない)。
 *    Esc は「文字があれば消す → 空なら閉じて呼び出し元へフォーカスを戻す」の2段。
 * 5. **⌘K / Ctrl+K はページ全体の keydown で拾う**が、一覧の縮小カード(rect.width ÷ offsetWidth が 1 未満)
 *    では反応させない。本番ではパネルを fixed で全面に出す。見本では枠の中に absolute で出している。
 * 6. 開閉の動きは opacity と translateY だけ。motion-reduce では遷移を切り、開いた状態がそのまま読める。
 *
 * 文言・項目・配色はすべて First CH のオリジナル。
 */

type Group = "ページ" | "操作" | "よく見る項目";

type Item = {
  id: string;
  label: string;
  group: Group;
  /** 右端に出す行き先(ページ)かショートカット(操作) */
  meta: string;
  /** 読みがな・ローマ字・英語名。空白区切り */
  keywords: string;
  /** 実行したときに出す一文 */
  done: string;
};

const ITEMS: Item[] = [
  { id: "top", label: "トップ", group: "ページ", meta: "/", keywords: "とっぷ top home ほーむ", done: "トップページを開きました" },
  { id: "service", label: "サービス一覧", group: "ページ", meta: "/service/", keywords: "さーびす service いちらん", done: "サービス一覧を開きました" },
  { id: "works", label: "制作実績", group: "ページ", meta: "/works/", keywords: "せいさくじっせき seisaku jisseki works", done: "制作実績を開きました" },
  { id: "price", label: "料金", group: "ページ", meta: "/price/", keywords: "りょうきん ryokin ryoukin price pricing ねだん", done: "料金のページを開きました" },
  { id: "about", label: "会社概要", group: "ページ", meta: "/about/", keywords: "かいしゃがいよう kaisha about", done: "会社概要を開きました" },
  { id: "recruit", label: "採用情報", group: "ページ", meta: "/recruit/", keywords: "さいよう saiyou recruit jobs", done: "採用情報を開きました" },
  { id: "news", label: "お知らせ", group: "ページ", meta: "/news/", keywords: "おしらせ oshirase news", done: "お知らせを開きました" },
  { id: "contact", label: "お問い合わせ", group: "ページ", meta: "/contact/", keywords: "おといあわせ toiawase contact", done: "お問い合わせのページを開きました" },
  { id: "estimate", label: "見積もりを依頼する", group: "操作", meta: "E", keywords: "みつもり mitsumori estimate quote", done: "見積もり依頼の入力欄を開きました" },
  { id: "download", label: "会社案内の資料を受け取る", group: "操作", meta: "D", keywords: "しりょう shiryou download pdf だうんろーど", done: "資料の送付先を入力する画面を開きました" },
  { id: "copy", label: "このページのリンクをコピー", group: "操作", meta: "C", keywords: "こぴー copy link url りんく", done: "リンクをコピーしました(見本のため実際には書き込みません)" },
  { id: "english", label: "英語で表示する", group: "操作", meta: "L", keywords: "えいご eigo english language げんご", done: "表示を英語に切り替えました(見本)" },
  { id: "flow", label: "制作の流れ", group: "よく見る項目", meta: "/service/flow/", keywords: "ながれ nagare flow すけじゅーる", done: "制作の流れを開きました" },
  { id: "faq", label: "よくある質問", group: "よく見る項目", meta: "/faq/", keywords: "しつもん shitsumon faq", done: "よくある質問を開きました" },
  { id: "delivery", label: "納期の目安", group: "よく見る項目", meta: "/faq/#delivery", keywords: "のうき nouki delivery schedule きかん", done: "納期の目安の項へ移動しました" },
  { id: "care", label: "公開後の保守プラン", group: "よく見る項目", meta: "/service/care/", keywords: "ほしゅ hoshu maintenance care うんよう", done: "保守プランを開きました" },
];

const GROUPS: Group[] = ["ページ", "操作", "よく見る項目"];
const RECENT_MAX = 3;
const RECENT_BONUS = 4;

// ---- 正規化とあいまい一致 --------------------------------------------------------------

function normalize(s: string) {
  return s
    .normalize("NFKC")
    .toLowerCase()
    .replace(/[ァ-ヶ]/g, (c) => String.fromCharCode(c.charCodeAt(0) - 0x60));
}

type Hit = { score: number; indices: number[] };

/** q の字が text に順番どおり含まれるなら点数と一致位置を返す。含まれなければ null */
function fuzzy(text: string, q: string): Hit | null {
  if (!q) return { score: 0, indices: [] };
  const t = normalize(text);
  let best: Hit | null = null;
  for (let start = t.indexOf(q[0]); start !== -1; start = t.indexOf(q[0], start + 1)) {
    const indices = [start];
    let score = start === 0 ? 5 : isWordStart(t, start) ? 3 : 1;
    let pos = start;
    let ok = true;
    for (let k = 1; k < q.length; k++) {
      const next = t.indexOf(q[k], pos + 1);
      if (next === -1) {
        ok = false;
        break;
      }
      score += 1;
      if (next === pos + 1) score += 3;
      else {
        if (isWordStart(t, next)) score += 2;
        score -= Math.min(3, (next - pos - 1) * 0.5);
      }
      indices.push(next);
      pos = next;
    }
    if (!ok) break; // 後ろの開始位置ほど残りの字が少ないので、ここで当たらなければ以降も当たらない
    if (!best || score > best.score) best = { score, indices };
  }
  return best;
}

function isWordStart(t: string, i: number) {
  return i === 0 || /[\s/・ー-]/.test(t[i - 1]);
}

type Row = { item: Item; hit: Hit | null; alias: { word: string; hit: Hit } | null; recent: boolean; section: string };

function rank(query: string, recent: string[]): Row[] {
  const q = normalize(query.trim()).replace(/\s+/g, "");
  if (!q) {
    const recentRows = recent
      .map((id) => ITEMS.find((it) => it.id === id))
      .filter((it): it is Item => Boolean(it))
      .map((item) => ({ item, hit: null, alias: null, recent: true, section: "最近使った項目" }));
    const rest = GROUPS.flatMap((g) =>
      ITEMS.filter((it) => it.group === g && !recent.includes(it.id)).map((item) => ({
        item,
        hit: null,
        alias: null,
        recent: false,
        section: g,
      })),
    );
    return [...recentRows, ...rest];
  }
  const scored: (Row & { score: number; order: number })[] = [];
  ITEMS.forEach((item, order) => {
    const onLabel = fuzzy(item.label, q);
    let kw: { word: string; hit: Hit } | null = null;
    for (const word of item.keywords.split(" ")) {
      const h = fuzzy(word, q);
      if (h && (!kw || h.score > kw.hit.score)) kw = { word, hit: h };
    }
    if (!onLabel && !kw) return;
    // 項目名に当たれば名前の字を強調する。点数は読み・別名の方が高ければそちらを割り引いて採る
    const useLabel = Boolean(onLabel);
    const isRecent = recent.includes(item.id);
    const base = Math.max(onLabel?.score ?? 0, kw ? kw.hit.score * 0.8 : 0);
    scored.push({
      item,
      hit: useLabel ? onLabel : null,
      alias: useLabel ? null : kw,
      recent: isRecent,
      section: "候補",
      score: base + (isRecent ? RECENT_BONUS : 0),
      order,
    });
  });
  scored.sort((a, b) => b.score - a.score || a.order - b.order);
  return scored.slice(0, 8);
}

function Highlight({ text, indices }: { text: string; indices: number[] }) {
  if (!indices.length) return <>{text}</>;
  const set = new Set(indices);
  return (
    <>
      {Array.from(text).map((ch, i) =>
        set.has(i) ? (
          <mark key={i} className="bg-transparent font-bold text-amber-700 underline decoration-amber-600 underline-offset-[3px]">
            {ch}
          </mark>
        ) : (
          <span key={i}>{ch}</span>
        ),
      )}
    </>
  );
}

// ---- 本体 -----------------------------------------------------------------------------

export default function CommandPaletteSearch() {
  const [open, setOpen] = useState(true);
  const [query, setQuery] = useState("");
  const [active, setActive] = useState(0);
  const [recent, setRecent] = useState<string[]>(["price", "faq"]);
  const [current, setCurrent] = useState<Item>(ITEMS[0]);
  const [message, setMessage] = useState("");

  const rootRef = useRef<HTMLElement | null>(null);
  const inputRef = useRef<HTMLInputElement | null>(null);
  const triggerRef = useRef<HTMLButtonElement | null>(null);
  const listRef = useRef<HTMLDivElement | null>(null);

  const uid = useId();
  const headingId = `${uid}-h`;
  const listId = `${uid}-list`;
  const optId = (i: number) => `${uid}-opt-${i}`;

  const rows = useMemo(() => rank(query, recent), [query, recent]);
  const safeActive = rows.length ? Math.min(active, rows.length - 1) : -1;

  const openPalette = () => {
    setOpen(true);
    // inert を外した後でないとフォーカスできないので、次のフレームで移す
    requestAnimationFrame(() => inputRef.current?.focus());
  };

  const closePalette = () => {
    const hadFocus = rootRef.current?.querySelector("[data-palette]")?.contains(document.activeElement);
    setOpen(false);
    setQuery("");
    setActive(0);
    if (hadFocus) requestAnimationFrame(() => triggerRef.current?.focus());
  };

  const run = (row: Row) => {
    const { item } = row;
    setRecent((prev) => [item.id, ...prev.filter((id) => id !== item.id)].slice(0, RECENT_MAX));
    if (item.group !== "操作") setCurrent(item);
    setMessage(item.done);
    closePalette();
  };

  // ⌘K / Ctrl+K はページ全体で拾う。一覧の縮小カードでは反応しない
  const toggleRef = useRef<() => void>(() => {});
  useEffect(() => {
    toggleRef.current = () => (open ? closePalette() : openPalette());
  });
  useEffect(() => {
    const onKey = (e: globalThis.KeyboardEvent) => {
      if (e.key.toLowerCase() !== "k" || !(e.metaKey || e.ctrlKey) || e.altKey || e.shiftKey) return;
      const el = rootRef.current;
      if (!el || el.offsetWidth === 0) return;
      if (el.getBoundingClientRect().width / el.offsetWidth < 0.75) return;
      e.preventDefault();
      toggleRef.current();
    };
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, []);

  // 選択行が一覧の外に出たら、一覧の中だけをスクロールして見せる(ページ全体は動かさない)
  useEffect(() => {
    const list = listRef.current;
    if (!list || safeActive < 0) return;
    const el = list.querySelector<HTMLElement>(`[data-index="${safeActive}"]`);
    if (!el) return;
    const top = el.offsetTop;
    const bottom = top + el.offsetHeight;
    if (top < list.scrollTop) list.scrollTop = top - 28;
    else if (bottom > list.scrollTop + list.clientHeight) list.scrollTop = bottom - list.clientHeight;
  }, [safeActive, rows]);

  const onInputKey = (e: KeyboardEvent<HTMLInputElement>) => {
    if (e.nativeEvent.isComposing || e.keyCode === 229) return;
    if (e.key === "ArrowDown" || e.key === "ArrowUp") {
      e.preventDefault();
      if (!rows.length) return;
      const d = e.key === "ArrowDown" ? 1 : -1;
      setActive((safeActive + d + rows.length) % rows.length);
    } else if (e.key === "Enter") {
      e.preventDefault();
      if (safeActive >= 0) run(rows[safeActive]);
    } else if (e.key === "Escape") {
      e.preventDefault();
      if (query) {
        setQuery("");
        setActive(0);
      } else closePalette();
    } else if (e.key === "Tab") {
      // パネルの中で折り返す(入力欄 ⇄ 閉じるボタン)
      e.preventDefault();
      rootRef.current?.querySelector<HTMLButtonElement>("[data-close]")?.focus();
    }
  };

  const onCloseKey = (e: KeyboardEvent<HTMLButtonElement>) => {
    if (e.key === "Tab") {
      e.preventDefault();
      inputRef.current?.focus();
    } else if (e.key === "Escape") {
      e.preventDefault();
      closePalette();
    }
  };

  return (
    <section ref={rootRef} aria-labelledby={headingId} className="@container w-full max-w-5xl bg-white text-gray-900">
      {/* 冒頭: 小見出しの英字ラベルは置かず、和文の見出しとリードだけで開く */}
      <div className="max-w-2xl">
        <h2 id={headingId} className="text-[clamp(1.45rem,3.4vw,2.1rem)] leading-[1.35] font-bold tracking-tight">
          キーを2つ押せば、
          <span className="whitespace-nowrap">どのページへも</span>
        </h2>
        <p className="mt-4 text-[14px] leading-[1.9] font-medium text-gray-600">⌘K(Windows は Ctrl+K)で検索窓が開きます。ページ名や操作名の一部を打つと候補が絞れ、読みがなや英語名でも当たります。直前に使った項目は先頭に残ります。</p>
      </div>

      <div className="mt-8 grid gap-8 border-t border-gray-200 pt-8 @2xl:grid-cols-[minmax(0,1fr)_15rem] @2xl:gap-9">
        {/* 舞台: 見本のサイト画面。パネルはこの枠の中に出す */}
        <div className="relative h-[31rem] overflow-hidden border border-gray-200 bg-[linear-gradient(180deg,#ffffff_0%,#f8f6f2_100%)]">
          <div inert={open} className="flex h-full flex-col">
            <div className="flex items-center justify-between gap-3 border-b border-gray-200 bg-white px-4 py-3">
              <span className={`${montserrat.className} text-[13px] font-semibold tracking-wide`}>First CH</span>
              <button
                ref={triggerRef}
                type="button"
                onClick={openPalette}
                aria-haspopup="dialog"
                aria-keyshortcuts="Meta+K Control+K"
                className="group flex min-w-0 items-center gap-3 border border-gray-300 bg-white py-1.5 pr-1.5 pl-3 text-[12px] text-gray-500 transition-colors hover:border-gray-900 hover:text-gray-900 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-600 motion-reduce:transition-none"
              >
                <span className="truncate">サイト内を検索</span>
                <kbd className={`${montserrat.className} flex-none border border-gray-200 bg-gray-50 px-1.5 py-px text-[10.5px] font-medium text-gray-600`}>
                  ⌘K
                </kbd>
              </button>
            </div>
            <div className="flex-1 px-4 py-5 @md:px-6">
              <p className={`${montserrat.className} text-[11px] font-medium text-gray-400`}>{current.meta}</p>
              <p className="mt-1 text-[18px] font-bold">{current.label}</p>
              <div aria-hidden="true" className="mt-4 space-y-2.5">
                <div className="h-2 w-11/12 bg-gray-200/70" />
                <div className="h-2 w-10/12 bg-gray-200/70" />
                <div className="h-2 w-8/12 bg-gray-200/70" />
              </div>
              <p role="status" aria-live="polite" className="mt-6 min-h-[1.8em] border-l-2 border-amber-600 pl-3 text-[12.5px] leading-[1.8] text-gray-700 empty:border-transparent">
                {message}
              </p>
            </div>
          </div>

          {/* 背面の幕とパネル。閉じている間も DOM に残し、inert と opacity で隠す */}
          <div
            aria-hidden="true"
            onClick={closePalette}
            className={`absolute inset-0 bg-stone-900/15 transition-opacity duration-200 motion-reduce:transition-none ${
              open ? "opacity-100" : "pointer-events-none opacity-0"
            }`}
          />
          <div
            data-palette
            role="dialog"
            aria-label="サイト内検索"
            inert={!open}
            className={`absolute inset-x-3 top-3 mx-auto flex max-h-[calc(100%-1.5rem)] max-w-[34rem] flex-col border border-gray-300 bg-white shadow-[0_18px_40px_-18px_rgba(28,25,23,0.35)] transition-[opacity,translate] duration-200 ease-out motion-reduce:transition-none @md:top-10 ${
              open ? "translate-y-0 opacity-100" : "pointer-events-none -translate-y-2 opacity-0"
            }`}
          >
            <div className="flex items-center gap-2 border-b border-gray-200 pr-2">
              <input
                ref={inputRef}
                type="text"
                role="combobox"
                aria-expanded={open}
                aria-controls={listId}
                aria-autocomplete="list"
                aria-activedescendant={safeActive >= 0 ? optId(safeActive) : undefined}
                aria-label="ページ・操作を検索"
                autoComplete="off"
                spellCheck={false}
                value={query}
                placeholder="ページ名・操作を入力(例: りょう)"
                onChange={(e) => {
                  setQuery(e.target.value);
                  setActive(0);
                }}
                onKeyDown={onInputKey}
                className="min-w-0 flex-1 bg-transparent px-4 py-3.5 text-[15px] placeholder:text-gray-400 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-amber-600"
              />
              <button
                data-close
                type="button"
                onClick={closePalette}
                onKeyDown={onCloseKey}
                className={`${montserrat.className} flex-none border border-gray-200 px-1.5 py-px text-[10.5px] font-medium text-gray-500 hover:border-gray-900 hover:text-gray-900 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-600`}
              >
                <span className="sr-only">閉じる</span>
                <span aria-hidden="true">Esc</span>
              </button>
            </div>

            <div ref={listRef} id={listId} role="listbox" aria-label="候補" className="relative min-h-0 flex-1 overflow-y-auto overscroll-contain py-1">
              {rows.map((row, i) => {
                const showSection = i === 0 || row.section !== rows[i - 1].section;
                const on = i === safeActive;
                return (
                  <div key={row.item.id} role="presentation">
                    {showSection && (
                      <p aria-hidden="true" className="mt-1 border-t border-gray-100 px-4 pt-2.5 pb-1 text-[11px] font-bold text-gray-400 first:mt-0 first:border-t-0">
                        {row.section === "候補" ? `候補 ${rows.length}件` : row.section}
                      </p>
                    )}
                    <div
                      id={optId(i)}
                      data-index={i}
                      role="option"
                      aria-selected={on}
                      onMouseDown={(e) => e.preventDefault()}
                      onPointerMove={() => i !== safeActive && setActive(i)}
                      onClick={() => run(row)}
                      className={`relative flex cursor-pointer items-baseline justify-between gap-3 py-2 pr-4 pl-5 text-[13.5px] ${on ? "bg-amber-50/70" : ""}`}
                    >
                      <span
                        aria-hidden="true"
                        className={`absolute top-1.5 bottom-1.5 left-0 w-[3px] origin-top bg-amber-600 transition-transform duration-150 motion-reduce:transition-none ${
                          on ? "scale-y-100" : "scale-y-0"
                        }`}
                      />
                      <span className="min-w-0">
                        <span className={on ? "text-gray-900" : "text-gray-700"}>
                          <Highlight text={row.item.label} indices={row.hit?.indices ?? []} />
                        </span>
                        {row.recent && row.section === "候補" && (
                          <span className="ml-2 border border-gray-200 px-1 text-[10px] text-gray-500">最近</span>
                        )}
                        {row.alias && (
                          <span className="ml-2 text-[11px] whitespace-nowrap text-gray-400">
                            <span className="sr-only">読み・別名で一致:</span>
                            <Highlight text={row.alias.word} indices={row.alias.hit.indices} />
                          </span>
                        )}
                      </span>
                      <span className="flex flex-none items-baseline gap-2 text-[10.5px] text-gray-400">
                        {row.section === "候補" && <span className="hidden @md:inline">{row.item.group}</span>}
                        <span className={`${montserrat.className} hidden font-medium @sm:inline`}>
                          {row.item.group === "操作" ? `⌘${row.item.meta}` : row.item.meta}
                        </span>
                      </span>
                    </div>
                  </div>
                );
              })}
              {rows.length === 0 && (
                <p role="status" className="px-4 py-6 text-[13px] leading-[1.8] text-gray-600">
                  「{query}」に当たる項目はありません。末尾の字を消すか、読みがなで打ってみてください。
                </p>
              )}
            </div>

            <p aria-hidden="true" className="flex flex-wrap gap-x-4 gap-y-1 border-t border-gray-200 px-4 py-2 text-[10.5px] text-gray-500">
              <span>↑↓ 選ぶ</span>
              <span>Enter 開く</span>
              <span>Esc 消す・閉じる</span>
            </p>
          </div>
        </div>

        {/* 並び順のきまり(候補の点数の付け方をそのまま文章にしたもの) */}
        <div className="min-w-0">
          <p className="text-[12px] font-bold tracking-widest text-gray-500">候補の並べ方</p>
          <ol className="mt-3 border-t border-gray-200">
            {[
              ["打った字が順番どおり含まれる", "間に別の字が挟まっても候補に残します。「さよう」で採用情報に当たります。"],
              ["続けて一致・語頭で一致なら上へ", "一致した字は下線で示します。飛ばした字が多いほど少し下がります。"],
              ["読みがな・英語名でも探す", "変換前のひらがなや、price・faq のような英語名でも当たります。"],
              ["最近使った項目を先頭に", "空欄のときは直近3件を最初に並べ、打ち始めてからも少し上へ寄せます。"],
            ].map(([term, body], i) => (
              <li key={term} className="grid grid-cols-[1.5rem_minmax(0,1fr)] gap-x-2 border-b border-gray-200 py-3">
                <span className={`${montserrat.className} text-[12px] font-semibold text-amber-700 tabular-nums`}>{i + 1}</span>
                <span>
                  <span className="block text-[13px] leading-[1.6] font-bold">{term}</span>
                  <span className="mt-1 block text-[12px] leading-[1.75] text-gray-500">{body}</span>
                </span>
              </li>
            ))}
          </ol>
          <dl className="mt-5 grid grid-cols-[auto_minmax(0,1fr)] gap-x-3 gap-y-2 text-[12px] text-gray-600">
            {[
              ["⌘K", "開く・閉じる(Ctrl+K)"],
              ["↑ ↓", "候補を選ぶ"],
              ["Enter", "選んだ項目を開く"],
              ["Esc", "文字を消す → 閉じる"],
            ].map(([k, v]) => (
              <div key={k} className="contents">
                <dt>
                  <kbd className={`${montserrat.className} inline-block min-w-[2.6rem] border border-gray-200 bg-gray-50 px-1.5 py-px text-center text-[10.5px] font-medium text-gray-700`}>
                    {k}
                  </kbd>
                </dt>
                <dd>{v}</dd>
              </div>
            ))}
          </dl>
        </div>
      </div>
    </section>
  );
}

Add to your project via shadcn CLI

npx shadcn@latest add https://designs.first-ch.com/r/command-palette-search.json