First CH Designs
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営業日以内(土日祝を除く)

記載の内容は表示サンプルです(最終更新 2026年9月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