概要
適用中のフィルタ条件をチップ(Pill)形式で横並びに表示し、✕ボタンで個別削除・「すべてクリア」で一括削除できる UI コンポーネントを Tailwind CSS で実装する。Badge は静的なラベルだが、Filter Chip は削除インタラクションを持つ点が異なる。
インストール
# Tailwind CSS が既にセットアップ済みであれば追加インストールは不要
npm install tailwindcss @tailwindcss/postcss
実装
フィルターチップコンポーネント
// components/FilterChip.tsx
type Props = {
label: string;
value: string;
onRemove: () => void;
};
export default function FilterChip({ label, value, onRemove }: Props) {
return (
<span className="inline-flex items-center gap-1.5 rounded-full bg-blue-50 px-3 py-1 text-sm font-medium text-blue-700 ring-1 ring-inset ring-blue-200">
<span className="text-blue-400">{label}:</span>
<span>{value}</span>
<button
type="button"
onClick={onRemove}
className="ml-0.5 flex h-4 w-4 items-center justify-center rounded-full text-blue-400 hover:bg-blue-100 hover:text-blue-600"
aria-label={`${label} フィルタを削除`}
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" className="h-3 w-3" fill="currentColor">
<path d="M12.78 4.28a.75.75 0 0 0-1.06-1.06L8 6.94 4.28 3.22a.75.75 0 0 0-1.06 1.06L6.94 8l-3.72 3.72a.75.75 0 1 0 1.06 1.06L8 9.06l3.72 3.72a.75.75 0 1 0 1.06-1.06L9.06 8l3.72-3.72z" />
</svg>
</button>
</span>
);
}
フィルターチップリスト
// components/FilterChipList.tsx
import FilterChip from "./FilterChip";
export type FilterEntry = {
key: string;
label: string;
value: string;
};
type Props = {
filters: FilterEntry[];
onRemove: (key: string) => void;
onClearAll: () => void;
};
export default function FilterChipList({ filters, onRemove, onClearAll }: Props) {
if (filters.length === 0) return null;
return (
<div className="flex flex-wrap items-center gap-2">
{filters.map((filter) => (
<FilterChip
key={filter.key}
label={filter.label}
value={filter.value}
onRemove={() => onRemove(filter.key)}
/>
))}
{filters.length > 1 && (
<button
type="button"
onClick={onClearAll}
className="text-sm text-gray-500 hover:text-gray-700 hover:underline"
>
すべてクリア
</button>
)}
</div>
);
}
URL フィルタと組み合わせた使用例
// components/SampleFilters.tsx
"use client";
import { Suspense } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import FilterChipList, { type FilterEntry } from "./FilterChipList";
const FILTER_LABELS: Record<string, string> = {
q: "キーワード",
category: "カテゴリ",
difficulty: "難易度",
};
function SampleFiltersInner() {
const router = useRouter();
const searchParams = useSearchParams();
const activeFilters: FilterEntry[] = Array.from(searchParams.entries())
.filter(([key]) => key in FILTER_LABELS)
.map(([key, value]) => ({
key,
label: FILTER_LABELS[key],
value,
}));
function removeFilter(key: string) {
const params = new URLSearchParams(searchParams.toString());
params.delete(key);
const qs = params.toString();
router.push(qs ? `?${qs}` : window.location.pathname);
}
function clearAll() {
router.push(window.location.pathname);
}
return (
<FilterChipList
filters={activeFilters}
onRemove={removeFilter}
onClearAll={clearAll}
/>
);
}
export default function SampleFilters() {
return (
<Suspense fallback={null}>
<SampleFiltersInner />
</Suspense>
);
}
バリエーション(サイズ・カラー)
// components/FilterChipVariants.tsx — スタイルバリエーション例
// グレー系(neutral)
<span className="inline-flex items-center gap-1.5 rounded-full bg-gray-100 px-3 py-1 text-sm font-medium text-gray-700 ring-1 ring-inset ring-gray-200">
{/* ... */}
</span>
// グリーン系(success)
<span className="inline-flex items-center gap-1.5 rounded-full bg-green-50 px-3 py-1 text-sm font-medium text-green-700 ring-1 ring-inset ring-green-200">
{/* ... */}
</span>
// 小サイズ(sm)
<span className="inline-flex items-center gap-1 rounded-full bg-blue-50 px-2 py-0.5 text-xs font-medium text-blue-700 ring-1 ring-inset ring-blue-200">
{/* ... */}
</span>
ポイント
- Filter Chip は削除ボタンを持つ点で Badge(静的ラベル)と区別する。
aria-labelで「○○ フィルタを削除」と読み上げさせてアクセシブルにする ring-1 ring-insetでチップに内側のアウトラインを付ける。borderより微妙な印象で、背景色と組み合わせたときに馴染みやすい- フィルタが 1 件のみのときは「すべてクリア」ボタンを非表示にしてノイズを減らす(
filters.length > 1の条件) useSearchParamsを使うコンポーネントは<Suspense>でラップする。Next.js App Router での要件Array.from(searchParams.entries())で全パラメータを走査し、FILTER_LABELSに定義したキーのみをフィルタとして扱う。ページネーションのpageなど内部パラメータがチップ表示されるのを防ぐ