BLK-56Blocks
事実表一体型CTA(営業時間・対応エリア併記)
信頼情報の表そのものを面の主役に据えたCTA。受付時間・定休日・対応エリア・出張範囲・登録番号・開業と実績・初回相談・返信目安の8項目を、鍵を小文字英字1語に固定した2カラムの台帳に組み、申し込みボタンはその台帳の最終行として全幅のアンバー帯になる。文とボタンを左右に分ける帯でも、選択や入力をさせる型でもなく、読み終わりと押す場所が同じ列で一致する構図。列挙の長い対応エリアだけは行内のトグルで全14市町を開ける。折り返しはビューポートではなくコンテナクエリ(@2xl)で決めるため、狭い枠に埋め込んでも崩れない。角丸・英字キッカー・末尾の矢印は使わず、hairline罫線と余白だけで束ねた。小さな注記はアンバー帯に載せずコントラストの取れる白地へ出している。事実はすべて架空の表示サンプル。画像素材ゼロ・依存ゼロ。
- 追加日:
- 2026-09-10
- 依存:
- なし
- tags
- #cta #section #block #facts #ledger #trust #hours #coverage #container-query #hairline #tabular-nums #brand #motion #no-image
プレビュー
相談先を決める材料を、
この一枚にまとめました
受付時間もお伺いできる範囲も下のとおりです。目を通したうえでご相談ください。
- 受付時間
- 平日 9:00–18:00 / 土曜 9:00–13:00
- 定休日
- 日曜・祝日(メールは終日受付)
- 対応エリア
- けやき市・さくら台市・なぎさ市 ほか全14市町
- 出張相談
- エリア内は無料(エリア外は実費)
- 登録番号
- ◯◯県知事登録 第00-000000号
- 開業・実績
- 2011年開業 / ご相談 累計 3,200件
- 初回相談
- 無料・60分(お見積りも無料)
- ご返信
- 1営業日以内(土日祝を除く)
"use client";
import { useEffect, useRef, useState } from "react";
import { Montserrat } from "next/font/google";
const montserrat = Montserrat({
weight: ["500", "600"],
subsets: ["latin"],
display: "swap",
preload: false,
});
/**
* 事実表一体型CTA / Facts-Ledger CTA
* 既存のCTAは「文+ボタン」を面や左右分割で置く型と、選択・入力を主役にする型が出揃っているため、
* これは *事実表そのものを面の主役に据え、行動をその台帳の下端に一体化させた* 型にした。
* 面積の大半は8項目の事実台帳(受付時間・定休日・対応エリア・出張・登録番号・実績・相談料・返信)で、
* ボタンは台帳の最終行として全幅の帯になる。既存CTAが持つ事実は最大でも3行なので情報密度でも別物。
* 角丸・英字キッカー・末尾の「→」は使わない。事実はすべて架空の表示サンプル。
*
* 汎用技法メモ(web-design-playbook 還流用):
* - 信頼情報を別セクションへ切らずCTAの面へ入れる: 「営業時間はどこ?」「自分の地域は対象?」は
* クリックを止める最後の疑問なので、答えを導線の直前ではなく *同じ面* に置く。構図としては
* 「左に文・右にボタン」ではなく「台帳が本体・行動が下端の縁」にすると、読み終わりと押す場所が一致する。
* - 台帳の鍵は小文字の英字1語で固定幅の列にする: dt を w-14 の小文字英字(uppercase + 広い字間の
* キッカーにはしない)にすると、値の和文が主になり、2カラムでも視線が鍵の列で揃う。
* 英字は読み上げから外し(aria-hidden)、和文の項目名を sr-only で dt に持たせる。
* - 長い項目だけ行内で開く: 対応エリアのように列挙が長い項目は、要約+行内のトグルで全件を出す。
* 開閉は高さの変化そのものなので transform/opacity では表現できない。トランジションを付けず
* 即時に差し替え、aria-expanded / aria-controls で状態を伝える。
* - 折り返しはビューポートではなくコンテナで決める: 親に @container を付け @2xl:(=672px)で
* 1→2カラムに切り替える。埋め込み先が狭いときに sm:/lg: で起きる「広い画面なのに箱は狭い」ズレが出ない。
* - 全幅のアンバー帯に小さな注記を載せない: 白文字は #d97706 上で 3.6:1 しかなく小さな字がAA未満になる。
* 注記は帯の外(白地・text-gray-500)へ出し、帯には大きめの太字ラベルだけを置く。
* 帯のフォーカスリングは白(focus-visible:-outline-offset-4 で内側に引く)にする。
*/
/** 数値は Montserrat + tabular-nums。時刻や番号の途中で折れないよう nowrap も持たせる */
function Num({ children }: { children: React.ReactNode }) {
return (
<span className={`${montserrat.className} whitespace-nowrap tabular-nums`}>
{children}
</span>
);
}
type Fact = {
/** 台帳の鍵。小文字英字1語で固定幅の列に置く(読み上げは term 側) */
key: string;
/** 読み上げ用の和文項目名 */
term: string;
value: React.ReactNode;
};
const FACTS: Fact[] = [
{
key: "hours",
term: "受付時間",
value: (
<>
平日 <Num>9:00–18:00</Num> / 土曜 <Num>9:00–13:00</Num>
</>
),
},
{
key: "closed",
term: "定休日",
value: <>日曜・祝日(メールは終日受付)</>,
},
// area は行内トグルを持つため、値をここでは持たせず本体で組み立てる
{ key: "area", term: "対応エリア", value: null },
{
key: "visit",
term: "出張相談",
value: <>エリア内は無料(エリア外は実費)</>,
},
{
key: "license",
term: "登録番号",
value: (
<>
◯◯県知事登録 第<Num>00-000000</Num>号
</>
),
},
{
key: "since",
term: "開業・実績",
value: (
<>
<Num>2011</Num>年開業 / ご相談 累計 <Num>3,200</Num>件
</>
),
},
{
key: "fee",
term: "初回相談",
value: (
<>
無料・<Num>60</Num>分(お見積りも無料)
</>
),
},
{
key: "reply",
term: "ご返信",
value: <>1営業日以内(土日祝を除く)</>,
},
];
/** 対応エリアの全件(地名は表示サンプル) */
const AREAS = [
"けやき市",
"さくら台市",
"なぎさ市",
"ひばりが丘市",
"南野市",
"東かえで市",
"若葉市",
"小山田市",
"桜川町",
"白鷺町",
"海原町",
"峰岸町",
"沢渡村",
"三日月村",
];
export default function FactsIntegratedCta() {
const rootRef = useRef<HTMLElement>(null);
const [inView, setInView] = useState(false);
const [areaOpen, setAreaOpen] = useState(false);
useEffect(() => {
const el = rootRef.current;
if (!el) return;
// 縦に長い節でも上端が入れば発火するよう threshold ではなく rootMargin で見る
const io = new IntersectionObserver(
(entries) => {
if (entries.some((e) => e.isIntersecting)) {
setInView(true);
io.disconnect();
}
},
{ threshold: 0, rootMargin: "0px 0px -10% 0px" },
);
io.observe(el);
return () => io.disconnect();
}, []);
const rise = (delay: number) => ({
className: `transition-[opacity,transform] duration-700 ease-out motion-reduce:transition-none ${
inView
? "translate-y-0 opacity-100"
: "translate-y-2 opacity-0 motion-reduce:translate-y-0 motion-reduce:opacity-100"
}`,
style: { transitionDelay: inView ? `${delay}ms` : "0ms" },
});
const heading = rise(0);
const lead = rise(80);
const footnote = rise(470);
const action = rise(540);
return (
<section
ref={rootRef}
aria-labelledby="facts-integrated-cta-heading"
className="@container w-full max-w-4xl border border-gray-200 bg-white"
>
{/* 導入は小さく。面の主役は下の台帳なので、ここで色も面も使わない */}
<div className="px-5 py-6 @2xl:px-8 @2xl:py-7">
<h2
id="facts-integrated-cta-heading"
className={`text-lg leading-[1.6] font-bold tracking-tight text-gray-900 @2xl:text-xl ${heading.className}`}
style={heading.style}
>
相談先を決める材料を、
<br />
この一枚にまとめました
</h2>
<p
className={`mt-3 max-w-lg text-[13px] leading-[1.9] text-gray-600 ${lead.className}`}
style={lead.style}
>
受付時間もお伺いできる範囲も下のとおりです。目を通したうえでご相談ください。
</p>
</div>
{/* 事実台帳。面積の大半をここが占める=これが本体 */}
<dl className="grid border-t border-gray-200 @2xl:grid-cols-2">
{FACTS.map((fact, i) => {
const row = rise(160 + i * 35);
const isArea = fact.key === "area";
return (
<div
key={fact.key}
className={`flex flex-col gap-0.5 border-b border-gray-100 px-5 py-3 @md:flex-row @md:items-baseline @md:gap-3 @2xl:px-8 @2xl:py-3.5 @2xl:odd:border-r @2xl:odd:border-r-gray-100 ${row.className}`}
style={row.style}
>
{/* 器が狭いときは鍵を値の上に積む(横並びのまま固定幅の鍵を置くと値が3行に折れる) */}
<dt className="text-[10.5px] leading-[2] font-medium text-gray-400 @md:w-14 @md:shrink-0">
{/* 見せるのは英字1語、読み上げるのは和文 */}
<span aria-hidden="true">{fact.key}</span>
<span className="sr-only">{fact.term}</span>
</dt>
<dd className="text-[13px] leading-[1.9] text-gray-800 @md:flex-1">
{isArea ? (
<>
けやき市・さくら台市・なぎさ市{" "}
<span className="whitespace-nowrap">
ほか全<Num>14</Num>市町
</span>
<button
type="button"
aria-expanded={areaOpen}
aria-controls="facts-integrated-cta-areas"
onClick={() => setAreaOpen((v) => !v)}
className="ml-2 align-baseline text-[11px] font-medium text-gray-700 underline underline-offset-4 transition-colors hover:text-amber-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-600"
>
{areaOpen ? "閉じる" : "全域を見る"}
</button>
{areaOpen && (
// 開閉は高さの変化そのものなのでアニメーションさせない
<ul
id="facts-integrated-cta-areas"
className="mt-2 flex flex-wrap gap-x-3 gap-y-1 text-[12px] leading-[1.8] text-gray-600"
>
{AREAS.map((area) => (
<li key={area}>{area}</li>
))}
</ul>
)}
</>
) : (
fact.value
)}
</dd>
</div>
);
})}
</dl>
{/* 注記と副導線。小さい字はアンバー帯に載せず白地に置く(コントラスト) */}
<div
className={`flex flex-col gap-2 px-5 py-4 text-[11px] leading-[1.9] text-gray-500 @2xl:flex-row @2xl:items-center @2xl:justify-between @2xl:px-8 ${footnote.className}`}
style={footnote.style}
>
<p>記載の内容は表示サンプルです(最終更新 2026年9月1日)</p>
<p className="flex flex-wrap items-center gap-x-4 gap-y-1">
<a
href="#"
className="font-medium text-gray-700 underline underline-offset-4 transition-colors hover:text-amber-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-600"
>
料金の目安を見る
</a>
<a
href="#"
className="font-medium text-gray-700 underline underline-offset-4 transition-colors hover:text-amber-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-600"
>
よくあるご質問
</a>
</p>
</div>
{/* 行動は台帳の最終行として下端に一体化させる(別の箱にしない・角は丸めない) */}
<div className={action.className} style={action.style}>
<a
href="#"
className="group relative flex items-center justify-center overflow-hidden bg-amber-600 px-5 py-4 text-center text-white focus-visible:outline-2 focus-visible:-outline-offset-4 focus-visible:outline-white"
>
<span
aria-hidden="true"
className="absolute inset-0 origin-left scale-x-0 bg-amber-700 transition-transform duration-500 ease-out group-hover:scale-x-100 group-focus-visible:scale-x-100 motion-reduce:transition-none"
/>
<span className="relative text-[15px] font-bold tracking-tight @2xl:text-base">
無料相談を申し込む
</span>
</a>
</div>
</section>
);
}
shadcn CLI でプロジェクトに追加
npx shadcn@latest add https://designs.first-ch.com/r/facts-integrated-cta.json