BLK-99Blocks
並べ替えと絞り込みができる仕様比較表
製品を行、価格・容量・出力・重さ・充電時間・寸法・対応機能を列に並べた、件数の多い仕様比較表。列見出しのボタンで昇順・降順を切り替え(th の aria-sort を更新)、上のチップで重さと対応機能を絞り込める。結果の件数と並び順は aria-live で読み上げ、条件はまとめて外せる。該当が0件のときは、どの条件を外すと何件になるかをボタンで示す。スマホでは機種名の列を固定して横スクロールし、端の影でまだ続きがあることを伝える。並べ替えたときだけ行が新しい位置へ滑らかに移り、動きを減らす設定では即座に並び替わる。角丸・英字キッカー・矢印リンクは使わず、hairline 罫線と余白で組む。画像素材ゼロ・追加依存ゼロ。
- 追加日:
- 2026-10-16
- 依存:
- なし
- tags
- #table #data #sort #filter #comparison #spec #sticky-column #accessibility #no-image
プレビュー
ポータブル電源 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>
);
}
shadcn CLI でプロジェクトに追加
npx shadcn@latest add https://designs.first-ch.com/r/sortable-spec-table.json