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,8001281501.61.5186×112×128
  • USB-C 100W
PS-180小型¥22,8001922002.41.8205×128×140
  • USB-C 100W
  • パススルー充電
PS-240小型¥29,8002563003.22.0230×150×165
  • ソーラー入力
  • パススルー充電
PS-300S小型¥39,8002886003.81.2248×162×170
  • USB-C 100W
  • ソーラー入力
  • パススルー充電
PS-500中容量¥54,8005126005.92.5290×190×210
  • USB-C 100W
  • ソーラー入力
PS-500P中容量¥64,8005121,0006.41.4296×196×212
  • USB-C 100W
  • ソーラー入力
  • パススルー充電
PS-700中容量¥79,8007161,0008.11.6320×210×236
  • USB-C 100W
  • ソーラー入力
  • パススルー充電
PS-800W中容量¥84,8008061,2009.62.2338×224×248
  • ソーラー入力
  • 拡張バッテリー
PS-1000大容量¥109,8001,0241,50011.41.7356×236×262
  • USB-C 100W
  • ソーラー入力
  • パススルー充電
  • 拡張バッテリー
PS-1000L大容量¥99,8001,0701,20012.63.0362×240×270
  • ソーラー入力
  • 拡張バッテリー
PS-1500大容量¥149,8001,5361,80016.22.0398×268×300
  • USB-C 100W
  • ソーラー入力
  • パススルー充電
  • 拡張バッテリー
PS-2000大容量¥189,8002,0482,20022.42.4430×290×320
  • USB-C 100W
  • ソーラー入力
  • パススルー充電
  • 拡張バッテリー
"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