レビュー済み·難易度: 初級·更新: 2026-04-18

React で検索キーワードにマッチした文字列をハイライト表示する

入力された検索語と一致するテキスト部分を分割して <mark> タグや span で強調表示するコンポーネントの実装例。

nextjssearch-filterui-component

対応バージョン

nextjs 15react 19

前提環境

React の基本的なコンポーネント実装と正規表現の基本を理解していること

概要

検索ワードを正規表現で split してテキストを分割し、一致部分に <mark> タグを付けてレンダリングする。大文字・小文字を無視した検索、特殊文字のエスケープ、空クエリ時のフォールバックを実装する。

インストール

# 追加インストールは不要

実装

ハイライトコンポーネント

// components/Highlight.tsx

type Props = {
  text: string;
  query: string;
  className?: string;
};

function escapeRegExp(str: string): string {
  return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}

export default function Highlight({ text, query, className }: Props) {
  // 空クエリや空白のみの場合はそのまま返す
  const trimmed = query.trim();
  if (!trimmed) {
    return <span className={className}>{text}</span>;
  }

  const pattern = new RegExp(`(${escapeRegExp(trimmed)})`, "gi");
  const parts = text.split(pattern);

  return (
    <span className={className}>
      {parts.map((part, i) =>
        pattern.test(part) ? (
          <mark
            key={i}
            className="rounded bg-yellow-200 px-0.5 text-yellow-900"
          >
            {part}
          </mark>
        ) : (
          <span key={i}>{part}</span>
        ),
      )}
    </span>
  );
}

複数フィールドに対応したリストコンポーネント

// components/SearchableList.tsx
"use client";

import { useState } from "react";
import Highlight from "./Highlight";

type Item = {
  id: number;
  name: string;
  description: string;
};

const ITEMS: Item[] = [
  { id: 1, name: "Next.js App Router", description: "React のサーバーコンポーネントとファイルベースルーティング" },
  { id: 2, name: "Tailwind CSS", description: "ユーティリティファースト CSS フレームワーク" },
  { id: 3, name: "Prisma ORM", description: "TypeScript ファースト な Node.js 向け ORM" },
  { id: 4, name: "Zod", description: "TypeScript ファースト なスキーマバリデーションライブラリ" },
];

export default function SearchableList() {
  const [query, setQuery] = useState("");

  const filtered = ITEMS.filter(
    (item) =>
      item.name.toLowerCase().includes(query.toLowerCase()) ||
      item.description.toLowerCase().includes(query.toLowerCase()),
  );

  return (
    <div className="space-y-4">
      <input
        type="search"
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder="キーワードで検索…"
        className="w-full rounded-lg border px-4 py-2 text-sm outline-none focus:ring-2 focus:ring-blue-300"
      />

      {filtered.length === 0 ? (
        <p className="py-8 text-center text-sm text-gray-400">
          「{query}」に一致するアイテムが見つかりません
        </p>
      ) : (
        <ul className="divide-y rounded-lg border">
          {filtered.map((item) => (
            <li key={item.id} className="px-4 py-3">
              <p className="font-medium text-gray-900">
                <Highlight text={item.name} query={query} />
              </p>
              <p className="mt-0.5 text-sm text-gray-500">
                <Highlight text={item.description} query={query} />
              </p>
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}

ページへの組み込み

// app/search/page.tsx
import SearchableList from "@/components/SearchableList";

export default function SearchPage() {
  return (
    <main className="mx-auto max-w-xl p-8">
      <h1 className="mb-6 text-2xl font-bold">ライブラリ検索</h1>
      <SearchableList />
    </main>
  );
}

ポイント

  • text.split(new RegExp(($), "gi")) はキャプチャグループ付きの正規表現で分割することで、一致した文字列も配列に含めて返す。これにより元の大文字・小文字を保持したまま <mark> で囲める
  • escapeRegExp でユーザー入力の特殊文字(. * ? など)をエスケープする。これを省略すると正規表現として解釈されて意図しないマッチやランタイムエラーが発生する
  • "gi" フラグの i で大文字・小文字を無視した検索を実現する。g はグローバルマッチで複数箇所を一度に置換するために必要
  • pattern.test(part) は RegExp の lastIndex を更新するため、split 後に同じインスタンスを使い回すと挙動がずれることがある。毎回新しい RegExp を生成するか、フラグに g を含めない別インスタンスでテストするとよい(上記サンプルでは splittest で同一インスタンスを使っているため、実運用では new RegExp(escapeRegExp(trimmed), "i")test 用に別途作成することを推奨)
  • <mark> は HTML のセマンティックタグで検索結果のハイライトに適切。Tailwind の bg-yellow-200text-yellow-900 でコントラスト比を確保する

注意点

nextjs-api-search は API 側の検索フィルタ実装。react-multi-filter は複数条件の絞り込み UI。これは検索語に一致するテキスト部分を分割して mark タグで強調表示するフロント側レンダリングパターンに特化。大文字小文字無視・特殊文字エスケープ・空クエリのガードを示す。

関連サンプル