Sortable & Filterable Spec Table
A dense spec comparison table with products as rows and price, capacity, output, weight, charge time, dimensions and features as columns. Buttons inside the column headers toggle ascending/descending order and update aria-sort on the th; chips above narrow the list by weight and supported features. The result count and current order are announced through an aria-live region, and all conditions can be cleared at once. When nothing matches, the table suggests which single condition to drop and how many products that would bring back. On narrow screens the product column stays pinned while the rest scrolls horizontally, with edge shadows that show more content is available. Rows glide to their new position only when the order changes, and snap instantly under reduced motion. Built with hairline rules and whitespace, without rounded corners, English kickers or arrow links. Zero images, zero extra dependencies.
- Added:
- 2026-10-16
- Dependencies:
- None
- tags
- #table #data #sort #filter #comparison #spec #sticky-column #accessibility #no-image
Preview
ポータブル電源 12機種の仕様比較
列の見出しを押すと、その項目で並べ替えます。もう一度押すと逆の順になります。上の条件で機種を絞り込めます。
価格は税込の参考価格・2026.10時点
重さ
対応すべて満たす
12件12件中 12件を表示・容量の少ない順
| 機種 | 寸法W×D×H mm | 対応 | |||||
|---|---|---|---|---|---|---|---|
| PS-120小型 | ¥16,800 | 128 | 150 | 1.6 | 1.5 | 186×112×128 |
|
| PS-180小型 | ¥22,800 | 192 | 200 | 2.4 | 1.8 | 205×128×140 |
|
| PS-240小型 | ¥29,800 | 256 | 300 | 3.2 | 2.0 | 230×150×165 |
|
| PS-300S小型 | ¥39,800 | 288 | 600 | 3.8 | 1.2 | 248×162×170 |
|
| PS-500中容量 | ¥54,800 | 512 | 600 | 5.9 | 2.5 | 290×190×210 |
|
| PS-500P中容量 | ¥64,800 | 512 | 1,000 | 6.4 | 1.4 | 296×196×212 |
|
| PS-700中容量 | ¥79,800 | 716 | 1,000 | 8.1 | 1.6 | 320×210×236 |
|
| PS-800W中容量 | ¥84,800 | 806 | 1,200 | 9.6 | 2.2 | 338×224×248 |
|
| PS-1000大容量 | ¥109,800 | 1,024 | 1,500 | 11.4 | 1.7 | 356×236×262 |
|
| PS-1000L大容量 | ¥99,800 | 1,070 | 1,200 | 12.6 | 3.0 | 362×240×270 |
|
| PS-1500大容量 | ¥149,800 | 1,536 | 1,800 | 16.2 | 2.0 | 398×268×300 |
|
| PS-2000大容量 | ¥189,800 | 2,048 | 2,200 | 22.4 | 2.4 | 430×290×320 |
|
"use client";
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { Montserrat } from "next/font/google";
const montserrat = Montserrat({
weight: ["500", "600", "700"],
subsets: ["latin"],
display: "swap",
});
/**
* 並べ替えと絞り込みができる仕様比較表(BLK-99)
*
* 既存の比較表は3プラン×機能の固定表で読むだけのため、本ブロックは12機種×8仕様の
* 件数の多い表を、利用者が列の並べ替えとチップの絞り込みで探す操作型にした。
* 英字キッカー・末尾の「→」リンク・角丸・スクロール出現アニメは使わず、動きは並べ替え時の行の移動だけ。
*
* 汎用技法メモ:
*
* 1. 並べ替えは「th の中の button」+ th の aria-sort
* th 自体に onClick を付けるとキーボードで押せない。th の中に幅いっぱいの button を置き、
* 状態は th の aria-sort(ascending / descending / none)で伝える。並べ替えていない列には
* aria-sort を付けない(全列に none を付けると読み上げがうるさくなる)。
*
* 2. 結果件数は aria-live="polite" の1か所にまとめる
* 「12件中4件を表示・重さの軽い順」のように、件数と並び順を1文で知らせる。
* 表の行そのものを live にすると全行が読み上げられるので避ける。
*
* 3. 該当0件のときは「どの条件を外すと何件になるか」を計算して出す
* 有効な条件を1つずつ外した件数を数え、1件以上になるものだけをボタンで提案する。
* 行き止まりで全部の条件を外させるより、利用者の意図を多く残せる。
*
* 4. 先頭列の固定と端の影
* border-collapse だと sticky セルの罫線がスクロールで流れるので、
* border-separate border-spacing-0 にしてセルごとに border-b を引く。
* scrollLeft を見て、左に送ったら固定列の右に影、右端に達していなければ右端に影を出す。
* 影は opacity だけで出し入れする。
*
* 5. 行の移動は FLIP(offsetTop の差 → transform で戻す)
* 並べ替え前後の各行の offsetTop を記録し、差分を translateY で打ち消してから 0 へ戻す。
* offsetTop は transform の影響を受けないので、動きの途中で再度並べ替えてもずれない。
* 新しく現れた行は opacity だけで出す。prefers-reduced-motion では何もしない。
*/
type FeatureKey = "usbc" | "solar" | "pass" | "expand";
type WeightKey = "all" | "light" | "middle" | "heavy";
type SortKey = "price" | "capacity" | "output" | "weight" | "charge";
type SortDir = "asc" | "desc";
type Unit = {
id: string;
name: string;
kind: string;
price: number;
capacity: number;
output: number;
weight: number;
charge: number;
size: string;
features: FeatureKey[];
};
const units: Unit[] = [
{ id: "ps120", name: "PS-120", kind: "小型", price: 16800, capacity: 128, output: 150, weight: 1.6, charge: 1.5, size: "186×112×128", features: ["usbc"] },
{ id: "ps180", name: "PS-180", kind: "小型", price: 22800, capacity: 192, output: 200, weight: 2.4, charge: 1.8, size: "205×128×140", features: ["usbc", "pass"] },
{ id: "ps240", name: "PS-240", kind: "小型", price: 29800, capacity: 256, output: 300, weight: 3.2, charge: 2.0, size: "230×150×165", features: ["solar", "pass"] },
{ id: "ps300s", name: "PS-300S", kind: "小型", price: 39800, capacity: 288, output: 600, weight: 3.8, charge: 1.2, size: "248×162×170", features: ["usbc", "solar", "pass"] },
{ id: "ps500", name: "PS-500", kind: "中容量", price: 54800, capacity: 512, output: 600, weight: 5.9, charge: 2.5, size: "290×190×210", features: ["usbc", "solar"] },
{ id: "ps500p", name: "PS-500P", kind: "中容量", price: 64800, capacity: 512, output: 1000, weight: 6.4, charge: 1.4, size: "296×196×212", features: ["usbc", "solar", "pass"] },
{ id: "ps700", name: "PS-700", kind: "中容量", price: 79800, capacity: 716, output: 1000, weight: 8.1, charge: 1.6, size: "320×210×236", features: ["usbc", "solar", "pass"] },
{ id: "ps800w", name: "PS-800W", kind: "中容量", price: 84800, capacity: 806, output: 1200, weight: 9.6, charge: 2.2, size: "338×224×248", features: ["solar", "expand"] },
{ id: "ps1000l", name: "PS-1000L", kind: "大容量", price: 99800, capacity: 1070, output: 1200, weight: 12.6, charge: 3.0, size: "362×240×270", features: ["solar", "expand"] },
{ id: "ps1000", name: "PS-1000", kind: "大容量", price: 109800, capacity: 1024, output: 1500, weight: 11.4, charge: 1.7, size: "356×236×262", features: ["usbc", "solar", "pass", "expand"] },
{ id: "ps1500", name: "PS-1500", kind: "大容量", price: 149800, capacity: 1536, output: 1800, weight: 16.2, charge: 2.0, size: "398×268×300", features: ["usbc", "solar", "pass", "expand"] },
{ id: "ps2000", name: "PS-2000", kind: "大容量", price: 189800, capacity: 2048, output: 2200, weight: 22.4, charge: 2.4, size: "430×290×320", features: ["usbc", "solar", "pass", "expand"] },
];
const featureLabels: Record<FeatureKey, string> = {
usbc: "USB-C 100W",
solar: "ソーラー入力",
pass: "パススルー充電",
expand: "拡張バッテリー",
};
const featureOrder: FeatureKey[] = ["usbc", "solar", "pass", "expand"];
const weightOptions: { key: WeightKey; label: string; test: (kg: number) => boolean }[] = [
{ key: "all", label: "すべて", test: () => true },
{ key: "light", label: "5kg未満", test: (kg) => kg < 5 },
{ key: "middle", label: "5〜10kg", test: (kg) => kg >= 5 && kg < 10 },
{ key: "heavy", label: "10kg以上", test: (kg) => kg >= 10 },
];
const columns: {
key: SortKey;
label: string;
unit: string;
order: Record<SortDir, string>;
format: (u: Unit) => string;
}[] = [
{ key: "price", label: "価格", unit: "税込", order: { asc: "価格の安い順", desc: "価格の高い順" }, format: (u) => `¥${u.price.toLocaleString("ja-JP")}` },
{ key: "capacity", label: "容量", unit: "Wh", order: { asc: "容量の少ない順", desc: "容量の多い順" }, format: (u) => u.capacity.toLocaleString("ja-JP") },
{ key: "output", label: "定格出力", unit: "W", order: { asc: "出力の低い順", desc: "出力の高い順" }, format: (u) => u.output.toLocaleString("ja-JP") },
{ key: "weight", label: "重さ", unit: "kg", order: { asc: "重さの軽い順", desc: "重さの重い順" }, format: (u) => u.weight.toFixed(1) },
{ key: "charge", label: "満充電", unit: "時間", order: { asc: "満充電の短い順", desc: "満充電の長い順" }, format: (u) => u.charge.toFixed(1) },
];
type Filters = { weight: WeightKey; features: FeatureKey[] };
function matches(unit: Unit, filters: Filters) {
const weightTest = weightOptions.find((w) => w.key === filters.weight)!.test;
return weightTest(unit.weight) && filters.features.every((f) => unit.features.includes(f));
}
function SortIcon({ state }: { state: SortDir | null }) {
return (
<svg viewBox="0 0 8 12" aria-hidden="true" className="h-3 w-2 shrink-0">
<path d="M4 1 7 4.5H1Z" className={state === "asc" ? "fill-amber-600" : "fill-gray-300"} />
<path d="M4 11 1 7.5h6Z" className={state === "desc" ? "fill-amber-600" : "fill-gray-300"} />
</svg>
);
}
export default function SortableSpecTable() {
const [filters, setFilters] = useState<Filters>({ weight: "all", features: [] });
const [sort, setSort] = useState<{ key: SortKey; dir: SortDir }>({ key: "capacity", dir: "asc" });
const [edge, setEdge] = useState({ start: true, end: true });
const scrollerRef = useRef<HTMLDivElement | null>(null);
const rowRefs = useRef(new Map<string, HTMLTableRowElement>());
const prevTops = useRef(new Map<string, number>());
const mounted = useRef(false);
const visible = useMemo(() => {
const col = sort.key;
const sign = sort.dir === "asc" ? 1 : -1;
return units
.filter((u) => matches(u, filters))
.sort((a, b) => (a[col] - b[col]) * sign || a.price - b.price);
}, [filters, sort]);
const activeConditions = [
...(filters.weight !== "all"
? [{
id: "weight",
label: weightOptions.find((w) => w.key === filters.weight)!.label,
without: { ...filters, weight: "all" as WeightKey },
}]
: []),
...filters.features.map((f) => ({
id: f,
label: featureLabels[f],
without: { ...filters, features: filters.features.filter((x) => x !== f) },
})),
];
const suggestions =
visible.length === 0
? activeConditions
.map((c) => ({ ...c, count: units.filter((u) => matches(u, c.without)).length }))
.filter((c) => c.count > 0)
.sort((a, b) => b.count - a.count)
: [];
const sortColumn = columns.find((c) => c.key === sort.key)!;
const status = `${units.length}件中 ${visible.length}件を表示・${sortColumn.order[sort.dir]}`;
const toggleFeature = (f: FeatureKey) =>
setFilters((prev) => ({
...prev,
features: prev.features.includes(f)
? prev.features.filter((x) => x !== f)
: [...prev.features, f],
}));
const changeSort = (key: SortKey) =>
setSort((prev) =>
prev.key === key ? { key, dir: prev.dir === "asc" ? "desc" : "asc" } : { key, dir: "asc" },
);
// 端の影: 左へ送ったら固定列の右、右端に達していなければ右端に出す
useEffect(() => {
const el = scrollerRef.current;
if (!el) return;
const update = () => {
const max = el.scrollWidth - el.clientWidth;
setEdge({ start: el.scrollLeft <= 1, end: el.scrollLeft >= max - 1 });
};
update();
el.addEventListener("scroll", update, { passive: true });
const ro = new ResizeObserver(update);
ro.observe(el);
return () => {
el.removeEventListener("scroll", update);
ro.disconnect();
};
}, []);
// FLIP: 並べ替え・絞り込みの前後で行の位置差を transform で埋める
useLayoutEffect(() => {
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const nextTops = new Map<string, number>();
rowRefs.current.forEach((row, id) => {
const top = row.offsetTop;
nextTops.set(id, top);
if (!mounted.current || reduce || typeof row.animate !== "function") return;
const before = prevTops.current.get(id);
if (before === undefined) {
row.animate([{ opacity: 0 }, { opacity: 1 }], { duration: 240, easing: "ease-out" });
} else if (before !== top) {
row.animate(
[{ transform: `translateY(${before - top}px)` }, { transform: "translateY(0)" }],
{ duration: 320, easing: "cubic-bezier(0.2, 0.7, 0.2, 1)" },
);
}
});
prevTops.current = nextTops;
mounted.current = true;
}, [visible]);
const hasFilters = activeConditions.length > 0;
const chipClass = (pressed: boolean) =>
`inline-flex items-center gap-1.5 border px-3 py-1.5 text-[12.5px] font-semibold transition-colors duration-150 motion-reduce:transition-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-600 ${
pressed
? "border-amber-600 bg-amber-50 text-amber-800"
: "border-gray-300 bg-white text-gray-600 hover:border-gray-400 hover:text-gray-900"
}`;
const stickyShadow = `after:pointer-events-none after:absolute after:inset-y-0 after:left-full after:w-3 after:bg-gradient-to-r after:from-gray-900/[0.08] after:to-transparent after:transition-opacity after:duration-200 motion-reduce:after:transition-none ${
edge.start ? "after:opacity-0" : "after:opacity-100"
}`;
return (
<section className="@container w-full max-w-5xl bg-white text-gray-900" aria-labelledby="spec-title">
<div className="@3xl:flex @3xl:items-end @3xl:justify-between @3xl:gap-10">
<div>
<h2 id="spec-title" className="text-[clamp(1.4rem,3.2cqw,1.9rem)] leading-[1.4] font-bold tracking-tight">
ポータブル電源 12機種の仕様比較
</h2>
<p className="mt-3 max-w-[38rem] text-[13.5px] leading-[1.9] text-gray-600">
列の見出しを押すと、その項目で並べ替えます。もう一度押すと逆の順になります。上の条件で機種を絞り込めます。
</p>
</div>
<p className="mt-3 shrink-0 text-[11px] font-medium text-gray-400 @3xl:mt-0">
価格は税込の参考価格・<span className={`${montserrat.className} tabular-nums`}>2026.10</span>時点
</p>
</div>
{/* 絞り込み */}
<div className="mt-7 border-y border-gray-200">
<div className="grid gap-y-3 py-4 @2xl:grid-cols-[6.5rem_1fr] @2xl:items-center">
<p id="spec-weight-label" className="text-[12px] font-bold text-gray-900">
重さ
</p>
<div role="group" aria-labelledby="spec-weight-label" className="flex flex-wrap gap-2">
{weightOptions.map((w) => (
<button
key={w.key}
type="button"
aria-pressed={filters.weight === w.key}
onClick={() => setFilters((prev) => ({ ...prev, weight: w.key }))}
className={chipClass(filters.weight === w.key)}
>
{w.label}
</button>
))}
</div>
</div>
<div className="grid gap-y-3 border-t border-gray-200 py-4 @2xl:grid-cols-[6.5rem_1fr] @2xl:items-center">
<p id="spec-feature-label" className="text-[12px] font-bold text-gray-900">
対応
<span className="ml-1.5 text-[11px] font-medium text-gray-400">すべて満たす</span>
</p>
<div role="group" aria-labelledby="spec-feature-label" className="flex flex-wrap gap-2">
{featureOrder.map((f) => {
const pressed = filters.features.includes(f);
return (
<button
key={f}
type="button"
aria-pressed={pressed}
onClick={() => toggleFeature(f)}
className={chipClass(pressed)}
>
<svg
viewBox="0 0 12 12"
aria-hidden="true"
className={`size-3 transition-opacity duration-150 motion-reduce:transition-none ${pressed ? "opacity-100" : "opacity-30"}`}
fill="none"
stroke="currentColor"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
>
{pressed ? <path d="M2.5 6.2 5 8.6 9.5 3.6" /> : <path d="M6 2.5v7M2.5 6h7" />}
</svg>
{featureLabels[f]}
</button>
);
})}
</div>
</div>
</div>
{/* 件数と一括解除 */}
<div className="mt-4 flex flex-wrap items-center justify-between gap-x-6 gap-y-2">
<p aria-live="polite" aria-atomic="true" className="text-[13px] font-semibold text-gray-700">
<span className={`${montserrat.className} text-[15px] font-bold text-amber-700 tabular-nums`}>
{visible.length}
</span>
<span className="ml-1">件</span>
<span className="sr-only">{status}</span>
<span aria-hidden="true" className="ml-3 text-[12px] font-medium text-gray-400">
全{units.length}件・{sortColumn.order[sort.dir]}
</span>
</p>
<button
type="button"
disabled={!hasFilters}
onClick={() => setFilters({ weight: "all", features: [] })}
className="text-[12.5px] font-semibold text-gray-700 underline decoration-gray-300 underline-offset-4 transition-colors hover:decoration-amber-600 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-600 disabled:cursor-not-allowed disabled:text-gray-300 disabled:no-underline motion-reduce:transition-none"
>
条件をすべて外す
</button>
</div>
{/* 表 */}
<div className="relative mt-3">
<div
ref={scrollerRef}
role="region"
aria-labelledby="spec-caption"
tabIndex={0}
className="overflow-x-auto focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-600"
>
<table className="w-full min-w-[880px] table-fixed border-separate border-spacing-0 text-left">
<caption id="spec-caption" className="pb-2 text-left text-[11px] font-medium text-gray-400">
ポータブル電源の仕様一覧(横にスクロールできます)
</caption>
<colgroup>
<col className="w-[8.5rem] @2xl:w-[10rem]" />
<col className="w-[7.5rem]" />
{columns.slice(1).map((c) => (
<col key={c.key} className="w-[6.5rem]" />
))}
<col className="w-[8.5rem]" />
<col className="w-[11rem]" />
</colgroup>
<thead>
<tr>
<th
scope="col"
className={`sticky left-0 z-20 border-b-2 border-gray-900 bg-white py-3 pr-3 align-bottom text-[12px] font-bold text-gray-900 ${stickyShadow}`}
>
機種
</th>
{columns.map((c) => {
const active = sort.key === c.key;
return (
<th
key={c.key}
scope="col"
aria-sort={active ? (sort.dir === "asc" ? "ascending" : "descending") : undefined}
className={`border-b-2 border-gray-900 p-0 align-bottom ${active ? "bg-amber-50/60" : ""}`}
>
<button
type="button"
onClick={() => changeSort(c.key)}
className="group flex w-full items-end justify-end gap-1.5 px-3 py-3 text-right transition-colors hover:bg-gray-50 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-amber-600 motion-reduce:transition-none"
>
<span className="flex flex-col items-end">
<span className={`text-[12px] font-bold ${active ? "text-amber-800" : "text-gray-900"}`}>{c.label}</span>
<span className={`${montserrat.className} text-[10px] font-medium text-gray-400`}>
{c.unit}
</span>
</span>
<span className="mb-0.5">
<SortIcon state={active ? sort.dir : null} />
</span>
</button>
</th>
);
})}
<th scope="col" className="border-b-2 border-gray-900 px-3 py-3 align-bottom text-[12px] font-bold text-gray-900">
寸法
<span className={`block ${montserrat.className} text-[10px] font-medium text-gray-400`}>
W×D×H mm
</span>
</th>
<th scope="col" className="border-b-2 border-gray-900 px-3 py-3 align-bottom text-[12px] font-bold text-gray-900">
対応
</th>
</tr>
</thead>
<tbody>
{visible.map((u) => (
<tr
key={u.id}
ref={(node) => {
if (node) rowRefs.current.set(u.id, node);
else rowRefs.current.delete(u.id);
}}
className="group/row"
>
<th
scope="row"
className={`sticky left-0 z-10 border-b border-gray-200 bg-white py-3 pr-3 align-top font-normal group-hover/row:bg-gray-50 ${stickyShadow}`}
>
<span className={`block ${montserrat.className} text-[13.5px] font-bold text-gray-900`}>
{u.name}
</span>
<span className="mt-0.5 block text-[11px] text-gray-500">{u.kind}</span>
</th>
{columns.map((c) => (
<td
key={c.key}
className={`border-b border-gray-200 px-3 py-3 text-right align-top ${montserrat.className} text-[13.5px] tabular-nums group-hover/row:bg-gray-50 ${
sort.key === c.key ? "bg-amber-50/60 font-semibold text-gray-900" : "font-medium text-gray-700"
}`}
>
{c.format(u)}
</td>
))}
<td className={`border-b border-gray-200 px-3 py-3 align-top ${montserrat.className} text-[12.5px] text-gray-600 tabular-nums group-hover/row:bg-gray-50`}>
{u.size}
</td>
<td className="border-b border-gray-200 px-3 py-3 align-top group-hover/row:bg-gray-50">
<ul className="flex flex-wrap gap-x-2.5 gap-y-1 text-[11.5px] leading-snug text-gray-600">
{featureOrder
.filter((f) => u.features.includes(f))
.map((f) => (
<li
key={f}
className={filters.features.includes(f) ? "font-semibold text-amber-800" : ""}
>
{featureLabels[f]}
</li>
))}
</ul>
</td>
</tr>
))}
{visible.length === 0 && (
<tr>
<td colSpan={8} className="border-b border-gray-200 p-0">
<div className="sticky left-0 w-[min(100cqw,40rem)] px-1 py-8">
<p className="text-[14px] font-bold text-gray-900">条件に合う機種はありません</p>
<p className="mt-2 text-[13px] leading-[1.9] text-gray-600">
条件を1つ外すと、次の件数になります。
</p>
<ul className="mt-4 flex flex-wrap gap-2">
{suggestions.map((s) => (
<li key={s.id}>
<button
type="button"
onClick={() => setFilters(s.without)}
className="inline-flex items-center gap-2 border border-gray-300 bg-white px-3 py-2 text-[12.5px] font-semibold text-gray-800 transition-colors hover:border-amber-600 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-600 motion-reduce:transition-none"
>
「{s.label}」を外す
<span className={`${montserrat.className} text-amber-700 tabular-nums`}>
{s.count}件
</span>
</button>
</li>
))}
</ul>
</div>
</td>
</tr>
)}
</tbody>
</table>
</div>
<div
aria-hidden="true"
className={`pointer-events-none absolute right-0 bottom-0 h-[calc(100%-1.75rem)] w-6 bg-gradient-to-l from-gray-900/[0.08] to-transparent transition-opacity duration-200 motion-reduce:transition-none ${
edge.end ? "opacity-0" : "opacity-100"
}`}
/>
</div>
</section>
);
}
Add to your project via shadcn CLI
npx shadcn@latest add https://designs.first-ch.com/r/sortable-spec-table.json