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