First CH Designs
BLK-35Blocks

FAQ②カテゴリタブ+2カラム

「料金について/進め方/納品後」のカテゴリタブで質問を絞り込むFAQブロック。開閉はネイティブの<details>/<summary>のまま、タブは roving tabindex と ←→キーに対応する。広い幅では前半/後半で割った2カラムに並べ、開いても隣の列が動かない。件数バッジはMontserrat + tabular-numsで桁が変わっても幅が揺れない。

追加日:
2026-08-23
依存:
なし
tags
#faq #tabs #accordion #details #accessibility #two-column #no-image

プレビュー

FAQ

よくあるご質問

カテゴリで絞り込めます。解決しない場合はお問い合わせください。

01見積りは無料ですか。

はい。ヒアリングとお見積りまでは費用をいただきません。ページ数・機能・原稿や写真のご用意状況をうかがい、内訳を明記した書面でご提示します。

02支払いのタイミングを教えてください。

着手時に半額、公開後に残額をお振り込みいただく2回払いが基本です。分割のご相談にも応じています。

03予算が決まっていない状態でも相談できますか。

問題ありません。目的とご希望の公開時期をうかがい、優先度の高い範囲から段階的に進める組み立てをご提案します。

04公開までどれくらいかかりますか。

小規模なサイトで約1ヶ月、標準的な企業サイトで2〜3ヶ月が目安です。原稿と写真のご用意が整っている場合はさらに短縮できます。

05原稿や写真がなくても進められますか。

取材にもとづく原稿作成と撮影の手配まで承ります。既存の資料をお預かりし、足りない部分だけを補う進め方も可能です。

06打ち合わせはオンラインでも可能ですか。

オンラインのみで完結した制作も多数あります。記録は共有ドキュメントに残し、決定事項をいつでも確認できる状態にします。

07公開後に自分たちで更新できますか。

お知らせや実績など、更新頻度の高いページは管理画面から編集できるようにします。操作手順書のお渡しと使い方の説明もセットです。

08不具合が起きたときの窓口はありますか。

公開後も担当が継続して対応します。表示崩れやフォームの不具合は、ご連絡いただいた当日中に一次回答を差し上げます。

個別のご事情に沿ったご説明もできます。

質問を送る
"use client";

import { useId, useRef, useState } from "react";
import { Montserrat } from "next/font/google";

const montserrat = Montserrat({
  weight: ["600", "700"],
  subsets: ["latin"],
  display: "swap",
});

/**
 * FAQ②カテゴリタブ+2カラム(BLK-35)
 *
 * 汎用技法メモ(web-design-playbook 還流用):
 *
 * 1. 絞り込みタブは「タブUI」として組む(ボタンの羅列にしない)
 *    role="tablist" / role="tab" / role="tabpanel" と aria-selected・aria-controls を
 *    正しく張ると、支援技術に「4つのうち2つ目」と件数つきで伝わる。
 *    タブ内は Tab キーで巡回させず **roving tabindex**(選択中だけ tabIndex=0、他は -1)にし、
 *    ←→/Home/End で移動する。移動と同時に選択も切り替える自動活性化でよい
 *    (パネルの中身が軽く、切り替えコストがない場合の推奨挙動)。
 *
 * 2. 開閉は details/summary のまま触らない
 *    JS で開閉状態を持つと、キーボード操作・検索での自動展開・印刷時の展開が全部自前になる。
 *    ネイティブの details なら summary が既定でフォーカス可能・Enter/Space で開閉でき、
 *    "ページ内検索で閉じた回答がヒットする"(hidden until found 相当)挙動もブラウザに任せられる。
 *    既定の三角マーカーは list-none と [&::-webkit-details-marker]:hidden の両方で消す。
 *
 * 3. 2カラムは「列に流す」のではなく前半/後半で割る
 *    columns-2(CSS multi-column)に details を流すと、1つ開いた瞬間に列の折り返し位置が
 *    変わり、無関係な質問が隣の列へ飛ぶ。**配列を半分に割って2つの stack として描く**と
 *    開閉しても他方の列が動かず、DOM順=上から下の読み順とも一致する
 *    (奇数/偶数で互い違いに配ると視覚順と読み上げ順がずれる)。
 *
 * 4. 高さのアニメーションはやらない
 *    details は閉じている間 display:none 相当なので height の遷移は素直に効かない
 *    (interpolate-size 等はまだ横断的に使えない)。代わりに**中身の div へ animation を1本**当てる。
 *    display:none から表示に変わった要素はアニメーションが再生され直すので、
 *    transform/opacity だけで「開いた感」が出せる。transition ではなく animation なのは、
 *    表示直後の1フレーム待ちが要らず取りこぼしがないため。
 *
 * 5. 件数バッジは等幅数字で
 *    タブ内の数字は桁が変わると pill の幅が揺れて並びがガタつく。Montserrat +
 *    tabular-nums で字送りを固定し、和文ラベルとはフォントを分けて情報の種類を見せる。
 *
 * 6. reduced-motion は同一 <style> 内で打ち消す
 *    keyframes を持つブロックは、DOM末尾の <style> が head のユーティリティより後勝ちするため、
 *    同じ <style> の @media (prefers-reduced-motion: reduce) で animation:none !important を当てる。
 *    Tailwind の motion-reduce: だけに頼ると opacity:0 のまま固まることがある。
 */

type Faq = {
  id: string;
  category: "price" | "flow" | "after";
  q: string;
  a: string;
};

const CATEGORIES = [
  { key: "all", label: "すべて", en: "All" },
  { key: "price", label: "料金について", en: "Pricing" },
  { key: "flow", label: "進め方", en: "Process" },
  { key: "after", label: "納品後", en: "After" },
] as const;

type CategoryKey = (typeof CATEGORIES)[number]["key"];

const FAQS: Faq[] = [
  {
    id: "estimate",
    category: "price",
    q: "見積りは無料ですか。",
    a: "はい。ヒアリングとお見積りまでは費用をいただきません。ページ数・機能・原稿や写真のご用意状況をうかがい、内訳を明記した書面でご提示します。",
  },
  {
    id: "payment",
    category: "price",
    q: "支払いのタイミングを教えてください。",
    a: "着手時に半額、公開後に残額をお振り込みいただく2回払いが基本です。分割のご相談にも応じています。",
  },
  {
    id: "budget",
    category: "price",
    q: "予算が決まっていない状態でも相談できますか。",
    a: "問題ありません。目的とご希望の公開時期をうかがい、優先度の高い範囲から段階的に進める組み立てをご提案します。",
  },
  {
    id: "period",
    category: "flow",
    q: "公開までどれくらいかかりますか。",
    a: "小規模なサイトで約1ヶ月、標準的な企業サイトで2〜3ヶ月が目安です。原稿と写真のご用意が整っている場合はさらに短縮できます。",
  },
  {
    id: "material",
    category: "flow",
    q: "原稿や写真がなくても進められますか。",
    a: "取材にもとづく原稿作成と撮影の手配まで承ります。既存の資料をお預かりし、足りない部分だけを補う進め方も可能です。",
  },
  {
    id: "remote",
    category: "flow",
    q: "打ち合わせはオンラインでも可能ですか。",
    a: "オンラインのみで完結した制作も多数あります。記録は共有ドキュメントに残し、決定事項をいつでも確認できる状態にします。",
  },
  {
    id: "update",
    category: "after",
    q: "公開後に自分たちで更新できますか。",
    a: "お知らせや実績など、更新頻度の高いページは管理画面から編集できるようにします。操作手順書のお渡しと使い方の説明もセットです。",
  },
  {
    id: "support",
    category: "after",
    q: "不具合が起きたときの窓口はありますか。",
    a: "公開後も担当が継続して対応します。表示崩れやフォームの不具合は、ご連絡いただいた当日中に一次回答を差し上げます。",
  },
];

const countOf = (key: CategoryKey) =>
  key === "all" ? FAQS.length : FAQS.filter((faq) => faq.category === key).length;

function PlusMark() {
  return (
    <svg
      viewBox="0 0 20 20"
      fill="none"
      stroke="currentColor"
      strokeWidth={1.4}
      strokeLinecap="round"
      aria-hidden="true"
      className="mt-0.5 size-4 shrink-0 text-amber-600 transition-transform duration-300 ease-out group-open:rotate-45 motion-reduce:transition-none"
    >
      <path d="M10 4v12M4 10h12" />
    </svg>
  );
}

function FaqItem({ faq, index }: { faq: Faq; index: number }) {
  return (
    <details
      className="faq-item group border-t border-gray-200"
      style={{ animationDelay: `${index * 45}ms` }}
    >
      <summary className="flex cursor-pointer list-none items-start gap-3 py-4 pr-1 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-600 [&::-webkit-details-marker]:hidden">
        <span
          className={`${montserrat.className} mt-[3px] shrink-0 text-[11px] font-semibold tracking-[0.08em] tabular-nums text-gray-400 transition-colors group-open:text-amber-600 motion-reduce:transition-none`}
        >
          {String(index + 1).padStart(2, "0")}
        </span>
        <span className="flex-1 text-[13px] leading-relaxed font-semibold text-gray-900 @xl:text-sm">
          {faq.q}
        </span>
        <PlusMark />
      </summary>
      <div className="faq-answer pb-5 pl-[calc(0.75rem+1.6em)]">
        <p className="text-[13px] leading-[1.9] text-gray-600">{faq.a}</p>
      </div>
    </details>
  );
}

export default function FaqCategoryTabs() {
  const uid = useId();
  const [active, setActive] = useState<CategoryKey>("all");
  const tabRefs = useRef<(HTMLButtonElement | null)[]>([]);

  const visible = active === "all" ? FAQS : FAQS.filter((faq) => faq.category === active);
  /** 前半/後半で割る(互い違いにすると視覚順と読み上げ順がずれる) */
  const half = Math.ceil(visible.length / 2);
  const columns = [visible.slice(0, half), visible.slice(half)];

  const onKeyDown = (event: React.KeyboardEvent<HTMLButtonElement>, index: number) => {
    const last = CATEGORIES.length - 1;
    let next: number | null = null;
    if (event.key === "ArrowRight") next = index === last ? 0 : index + 1;
    else if (event.key === "ArrowLeft") next = index === 0 ? last : index - 1;
    else if (event.key === "Home") next = 0;
    else if (event.key === "End") next = last;
    if (next === null) return;
    event.preventDefault();
    setActive(CATEGORIES[next].key);
    tabRefs.current[next]?.focus();
  };

  return (
    <section
      className="@container w-full max-w-[720px] text-gray-900"
      aria-labelledby={`${uid}-title`}
    >
      <div className="flex flex-col gap-3 @xl:flex-row @xl:items-end @xl:justify-between">
        <div>
          <p
            className={`${montserrat.className} text-[11px] font-bold tracking-[0.28em] text-amber-600 uppercase`}
          >
            FAQ
          </p>
          <h2
            id={`${uid}-title`}
            className="mt-3 text-2xl font-bold tracking-tight @xl:text-[28px]"
          >
            よくあるご質問
          </h2>
        </div>
        <p className="text-xs leading-relaxed text-gray-500">
          カテゴリで絞り込めます。解決しない場合はお問い合わせください。
        </p>
      </div>

      <div
        role="tablist"
        aria-label="よくあるご質問のカテゴリ"
        className="mt-6 flex flex-wrap gap-2 border-b border-gray-200 pb-5"
      >
        {CATEGORIES.map((category, index) => {
          const selected = category.key === active;
          return (
            <button
              key={category.key}
              ref={(node) => {
                tabRefs.current[index] = node;
              }}
              type="button"
              role="tab"
              id={`${uid}-tab-${category.key}`}
              aria-selected={selected}
              aria-controls={`${uid}-panel`}
              tabIndex={selected ? 0 : -1}
              onClick={() => setActive(category.key)}
              onKeyDown={(event) => onKeyDown(event, index)}
              className={`inline-flex items-center gap-2 rounded-full border px-3.5 py-2 text-xs font-semibold transition-colors duration-200 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-600 motion-reduce:transition-none ${
                selected
                  ? "border-amber-600 bg-amber-600 text-white"
                  : "border-gray-200 bg-white text-gray-600 hover:border-gray-300 hover:bg-gray-50"
              }`}
            >
              <span className="whitespace-nowrap">{category.label}</span>
              <span
                className={`${montserrat.className} text-[10px] font-semibold tabular-nums ${
                  selected ? "text-white/70" : "text-gray-400"
                }`}
              >
                {String(countOf(category.key)).padStart(2, "0")}
              </span>
            </button>
          );
        })}
      </div>

      <div
        key={active}
        role="tabpanel"
        id={`${uid}-panel`}
        aria-labelledby={`${uid}-tab-${active}`}
        tabIndex={-1}
        className="mt-1 grid gap-x-10 @2xl:grid-cols-2"
      >
        {columns.map((column, columnIndex) => (
          <div
            key={columnIndex}
            /* 1カラムに畳んだときは列同士が縦に連なるので、上の列の下罫線は消す
               (下の列の先頭 details が持つ border-t と重なって2pxの太い線になる) */
            className={`border-gray-200 empty:hidden ${
              columnIndex === 1 || columns[1].length === 0 ? "border-b" : "@2xl:border-b"
            }`}
          >
            {column.map((faq, index) => (
              <FaqItem
                key={faq.id}
                faq={faq}
                index={columnIndex === 0 ? index : half + index}
              />
            ))}
          </div>
        ))}
      </div>

      <div className="mt-6 flex flex-col gap-3 @xl:flex-row @xl:items-center @xl:justify-between">
        <p className="text-xs leading-relaxed text-gray-500">
          個別のご事情に沿ったご説明もできます。
        </p>
        <a
          href="#"
          className="group inline-flex items-center gap-1.5 self-start rounded-lg bg-amber-600 px-4 py-2.5 text-xs font-semibold text-white transition-colors hover:bg-amber-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-600 motion-reduce:transition-none"
        >
          質問を送る
          <span
            aria-hidden="true"
            className="transition-transform duration-200 group-hover:translate-x-0.5 motion-reduce:transform-none motion-reduce:transition-none"
          >
            →
          </span>
        </a>
      </div>

      <style>{`
        @keyframes faq-item-in {
          from { opacity: 0; transform: translateY(6px); }
          to   { opacity: 1; transform: translateY(0); }
        }
        @keyframes faq-answer-in {
          from { opacity: 0; transform: translateY(-4px); }
          to   { opacity: 1; transform: translateY(0); }
        }
        .faq-item { animation: faq-item-in 420ms cubic-bezier(0.22, 1, 0.36, 1) both; }
        .faq-answer { animation: faq-answer-in 260ms ease-out both; }
        @media (prefers-reduced-motion: reduce) {
          .faq-item, .faq-answer { animation: none !important; }
        }
      `}</style>
    </section>
  );
}

shadcn CLI でプロジェクトに追加

npx shadcn@latest add https://designs.first-ch.com/r/faq-category-tabs.json