First CH Designs
BLK-52Blocks

Phone-First CTA Band (Tap-to-Call)

A CTA band that makes the telephone the primary path. The largest type is neither the heading nor a button but the number itself, and that number is the tel: tap target: one anchor that renders as a full-width amber bar on narrow screens and reverts to plain oversized type with a hover underline from sm up. A status line at the top derives open/closed (and the next opening time) from the current clock, while the right column holds a weekday/Saturday/Sunday hours table with today's row marked. Free-call notes stay secondary; email and callback links drop to thin underlined text. No rounded corners, no Latin kicker, no trailing arrows — hairline rules and spacing do the binding. All numbers and hours are display samples.

Added:
2026-09-05
Dependencies:
None
tags
#cta #section #block #tel #conversion #hairline #tabular-nums #brand #motion #no-image

Preview

お電話でのご相談

受付状況を確認しています

お急ぎのご相談は、
そのままお電話でどうぞ

タップで発信0120-000-000

通話料無料ご相談のみでも承ります担当者へおつなぎします

受付時間

平日
09:00–18:00
土曜
09:00–13:00
日曜・祝日

お急ぎでなければメールでのご相談も承ります。
時間外は折り返しのご予約をどうぞ。

"use client";

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

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

/**
 * 電話一次CTA帯 / Phone-First CTA Band
 * 既存のCTAは「文とボタン」で押す型(中央寄せの面・左右分割の帯・配布物と最小フォーム)が揃っているため、
 * これは *ボタンを主役にしない* 型にした。最大の活字は電話番号そのもので、番号がそのまま tel: の
 * タップ領域になる(SPでは全幅のアンバー面、PCでは下線を引く大活字)。フォームは細字の副導線へ落とし、
 * 代わりに受付時間の表と「いま受付時間内か」の現在時刻判定を持たせて情報密度で別物にしている。
 * 番号・時間はすべて架空の表示サンプル。
 *
 * 汎用技法メモ(web-design-playbook 還流用):
 * - 電話CTAの主役は番号の活字であってボタンではない: 番号を tabular-nums の大活字(clamp)で置き、
 *   それ自体を <a href="tel:"> にする。ボタンを別に置くと「番号を読む人」と「タップする人」で
 *   視線の到達点が2つに割れる。1つの要素が読み物とタップ領域を兼ねる形にする。
 * - 同じアンカーを幅で「面 → 活字」に切り替える: 狭い幅は w-full bg-amber-600 text-white の全幅ボタン、
 *   sm: から sm:w-auto sm:bg-transparent sm:text-gray-900 sm:p-0 で素の大活字に戻す。要素を2つ用意して
 *   hidden で出し分けると tel: リンクがDOMに2つ残り、支援技術に同じ番号が二重に読まれる。
 * - 営業状態は「静的な受付時間の表」+「現在時刻の判定」の二段で出す: 表は常に出しておき、
 *   判定結果(受付時間内 / 次の受付)だけを後から重ねる。判定は必ず useEffect 内で行い、初期描画は
 *   ニュートラル表示にする(サーバとクライアントで時刻が違うため、直接描くと hydration が壊れる)。
 * - 番号は1文字ずつ立ち上げる: 桁を span に割って translate-y + opacity を 40ms ずつずらす。
 *   分割はレンダー時に決定的に行い(配列は毎回同じ)、区切りのハイフンにも同じ遅延を通す。
 *   電話番号は折り返し禁止なので、文字分解しても和文のような改行位置の副作用が出ない。
 * - 角丸・英字キッカー・末尾の「→」を使わない: 罫線と余白だけで束ね、副導線は下線のテキストにする。
 *   「→」を付けると副導線が主導線と同じ強さで読まれ、電話への一次化が崩れる。
 */

/** 受付時間(表示サンプル)。days は Date#getDay() の値。 */
const HOURS = [
  { label: "平日", days: [1, 2, 3, 4, 5], open: 9 * 60, close: 18 * 60 },
  { label: "土曜", days: [6], open: 9 * 60, close: 13 * 60 },
  { label: "日曜・祝日", days: [0], open: null, close: null },
] as const;

const DAY_NAMES = ["日", "月", "火", "水", "木", "金", "土"];

const TEL_DISPLAY = "0120-000-000";
/** tel: に渡す値は表示用のハイフンを落とした数字だけにする */
const TEL_HREF = `tel:${TEL_DISPLAY.replace(/[^0-9+]/g, "")}`;

function formatMinutes(m: number) {
  const h = Math.floor(m / 60);
  return `${String(h).padStart(2, "0")}:${String(m % 60).padStart(2, "0")}`;
}

function slotOf(day: number) {
  return HOURS.find((h) => (h.days as readonly number[]).includes(day));
}

type Status = { open: boolean; note: string; todayIndex: number };

/** 現在時刻から受付状態を求める(クライアントでのみ呼ぶ) */
function currentStatus(now: Date): Status {
  const day = now.getDay();
  const minutes = now.getHours() * 60 + now.getMinutes();
  const today = slotOf(day);
  const todayIndex = HOURS.findIndex((h) => h === today);

  if (today && today.open !== null && today.close !== null) {
    if (minutes >= today.open && minutes < today.close) {
      return {
        open: true,
        note: `本日は ${formatMinutes(today.close)} まで受け付けています`,
        todayIndex,
      };
    }
    if (minutes < today.open) {
      return {
        open: false,
        note: `本日 ${formatMinutes(today.open)} から受け付けます`,
        todayIndex,
      };
    }
  }

  // 翌日以降で最初に受付のある曜日を探す
  for (let i = 1; i <= 7; i += 1) {
    const next = slotOf((day + i) % 7);
    if (next && next.open !== null) {
      const name = DAY_NAMES[(day + i) % 7];
      return {
        open: false,
        note: `次の受付は ${name}曜 ${formatMinutes(next.open)} から`,
        todayIndex,
      };
    }
  }
  return { open: false, note: "受付時間外です", todayIndex };
}

export default function TelPrimaryCta() {
  const rootRef = useRef<HTMLElement>(null);
  const [inView, setInView] = useState(false);
  // 時刻依存の表示は必ずマウント後に決める(SSRとクライアントで値が変わるため)
  const [status, setStatus] = useState<Status | null>(null);

  // 判定はマウント後の1フレーム目で行う(effect 内で同期に setState するとカスケード描画になる)
  useEffect(() => {
    const id = requestAnimationFrame(() => setStatus(currentStatus(new Date())));
    return () => cancelAnimationFrame(id);
  }, []);

  useEffect(() => {
    const el = rootRef.current;
    if (!el) return;
    const io = new IntersectionObserver(
      (entries) => {
        if (entries.some((e) => e.isIntersecting)) {
          setInView(true);
          io.disconnect();
        }
      },
      { threshold: 0.15 },
    );
    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 label = rise(0);
  const heading = rise(90);
  const notes = rise(430);
  const aside = rise(520);

  return (
    <section
      ref={rootRef}
      aria-labelledby="tel-primary-cta-heading"
      className="w-full max-w-4xl border border-gray-200 bg-white"
    >
      {/* 最上段: 用件のラベルと、現在時刻から求めた受付状態 */}
      <div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-1 border-b border-gray-200 px-5 py-2.5 sm:px-8">
        <p
          className={`flex items-center gap-2 text-[11px] font-bold tracking-[0.12em] text-gray-900 ${label.className}`}
          style={label.style}
        >
          <span
            aria-hidden="true"
            className="inline-block size-1 shrink-0 bg-amber-600"
          />
          お電話でのご相談
        </p>
        <p
          className={`flex items-center gap-1.5 text-[11px] ${
            status?.open ? "text-gray-900" : "text-gray-500"
          } ${label.className}`}
          style={{ transitionDelay: inView ? "60ms" : "0ms" }}
        >
          <span
            aria-hidden="true"
            className={`inline-block size-1.5 shrink-0 ${
              status?.open ? "bg-amber-600" : "bg-gray-300"
            }`}
          />
          {status ? (
            <>
              <span className="font-bold">
                {status.open ? "ただいま受付中" : "受付時間外"}
              </span>
              <span className="text-gray-500">{status.note}</span>
            </>
          ) : (
            // マウント前のニュートラル表示(時刻を描かないので hydration がずれない)
            <span className="text-gray-500">受付状況を確認しています</span>
          )}
        </p>
      </div>

      <div className="flex flex-col lg:flex-row lg:items-stretch">
        {/* 左: 番号そのものが主役。SPでは全幅のタップ領域、PCでは大活字に戻る */}
        <div className="flex flex-col justify-center px-5 py-7 sm:px-8 sm:py-9 lg:flex-1 lg:px-9 lg:py-10">
          <h2
            id="tel-primary-cta-heading"
            className={`text-base leading-[1.6] font-bold tracking-tight text-gray-900 sm:text-lg ${heading.className}`}
            style={heading.style}
          >
            お急ぎのご相談は、
            <br />
            そのままお電話でどうぞ
          </h2>

          <a
            href={TEL_HREF}
            className="group mt-4 flex w-full flex-col items-center gap-1 bg-amber-600 px-4 py-3.5 text-white transition-transform duration-300 ease-out hover:-translate-y-0.5 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-600 motion-reduce:transition-none motion-reduce:hover:translate-y-0 sm:mt-3 sm:w-auto sm:flex-row sm:justify-start sm:gap-2.5 sm:bg-transparent sm:p-0 sm:text-gray-900 sm:hover:translate-y-0"
          >
            {/* 狭い幅は「発信できる」ことを先に言う小さな行。番号と同じ行に置くと数字に押し出される */}
            <span className="flex items-center gap-1.5 text-[11px] font-bold sm:hidden">
              <svg
                aria-hidden="true"
                viewBox="0 0 24 24"
                fill="none"
                stroke="currentColor"
                strokeWidth="1.8"
                strokeLinecap="round"
                strokeLinejoin="round"
                className="size-3.5 shrink-0"
              >
                <path d="M6.5 3.5h3l1.5 4-2 1.4a12 12 0 0 0 5.1 5.1l1.4-2 4 1.5v3a1.5 1.5 0 0 1-1.6 1.5C10.9 17.6 6.4 13.1 5 5.1A1.5 1.5 0 0 1 6.5 3.5Z" />
              </svg>
              タップで発信
            </span>

            {/* PCは番号の隣に受話器。数字を読ませたいので補助ラベルは出さない */}
            <svg
              aria-hidden="true"
              viewBox="0 0 24 24"
              fill="none"
              stroke="currentColor"
              strokeWidth="1.8"
              strokeLinecap="round"
              strokeLinejoin="round"
              className="hidden size-6 shrink-0 text-amber-600 sm:block"
            >
              <path d="M6.5 3.5h3l1.5 4-2 1.4a12 12 0 0 0 5.1 5.1l1.4-2 4 1.5v3a1.5 1.5 0 0 1-1.6 1.5C10.9 17.6 6.4 13.1 5 5.1A1.5 1.5 0 0 1 6.5 3.5Z" />
            </svg>

            <span className="relative inline-block">
              {/* 桁を1文字ずつ立ち上げる。電話番号は折り返さないので分割の副作用が出ない */}
              <span
                className={`${montserrat.className} block text-2xl leading-none font-bold tracking-tight whitespace-nowrap tabular-nums sm:text-[clamp(2rem,4.4vw,3.1rem)]`}
              >
                {Array.from(TEL_DISPLAY).map((ch, i) => (
                  <span
                    key={`${ch}-${i}`}
                    className={`inline-block transition-[opacity,transform] duration-500 ease-out motion-reduce:transition-none ${
                      inView
                        ? "translate-y-0 opacity-100"
                        : "translate-y-1.5 opacity-0 motion-reduce:translate-y-0 motion-reduce:opacity-100"
                    }`}
                    style={{
                      transitionDelay: inView ? `${180 + i * 40}ms` : "0ms",
                    }}
                  >
                    {ch}
                  </span>
                ))}
              </span>
              {/* 下線は大活字のときだけ。hover で左から引く */}
              <span
                aria-hidden="true"
                className={`absolute -bottom-1.5 left-0 hidden h-[3px] w-full origin-left scale-x-0 bg-amber-600 transition-transform duration-500 ease-out group-hover:scale-x-100 group-focus-visible:scale-x-100 motion-reduce:transition-none sm:block`}
              />
            </span>
          </a>

          <p
            className={`mt-4 flex flex-col items-start gap-1 text-[11px] leading-[1.9] text-gray-500 sm:mt-5 sm:flex-row sm:flex-wrap sm:items-center sm:gap-x-2.5 sm:gap-y-0.5 ${notes.className}`}
            style={notes.style}
          >
            <span>通話料無料</span>
            <span
              aria-hidden="true"
              className="hidden h-2.5 w-px shrink-0 bg-gray-300 sm:block"
            />
            <span>ご相談のみでも承ります</span>
            <span
              aria-hidden="true"
              className="hidden h-2.5 w-px shrink-0 bg-gray-300 sm:block"
            />
            <span>担当者へおつなぎします</span>
          </p>
        </div>

        {/* 右: 受付時間の表と、細字に落とした副導線 */}
        <div className="flex shrink-0 flex-col justify-center border-t border-gray-200 px-5 py-6 sm:px-8 lg:w-[292px] lg:border-t-0 lg:border-l lg:px-8 lg:py-10">
          <div className={aside.className} style={aside.style}>
            <p className="text-[11px] font-bold tracking-[0.12em] text-gray-900">
              受付時間
            </p>
            <dl className="mt-2 divide-y divide-gray-100 border-y border-gray-200">
              {HOURS.map((row, i) => {
                const isToday = status?.todayIndex === i;
                return (
                  <div
                    key={row.label}
                    className={`flex items-baseline justify-between gap-3 py-2 text-[12.5px] ${
                      isToday ? "text-gray-900" : "text-gray-600"
                    }`}
                  >
                    <dt className="flex items-center gap-1.5">
                      {/* 今日の行の印。マウント後にだけ立つ */}
                      <span
                        aria-hidden="true"
                        className={`inline-block h-3 w-px shrink-0 ${
                          isToday ? "bg-amber-600" : "bg-transparent"
                        }`}
                      />
                      <span className={isToday ? "font-bold" : undefined}>
                        {row.label}
                      </span>
                      {isToday && (
                        <span className="text-[10px] text-amber-700">本日</span>
                      )}
                    </dt>
                    <dd
                      className={`${montserrat.className} shrink-0 text-[12px] tabular-nums ${
                        row.open === null ? "text-gray-400" : ""
                      }`}
                    >
                      {row.open === null || row.close === null
                        ? "休"
                        : `${formatMinutes(row.open)}–${formatMinutes(row.close)}`}
                    </dd>
                  </div>
                );
              })}
            </dl>

            {/* 副導線は細字の下線リンク。矢印は付けず、電話との主従を崩さない */}
            <p className="mt-4 text-[11px] leading-[2] text-gray-500">
              お急ぎでなければ
              <a
                href="#"
                className="mx-1 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>
              も承ります。
              <br />
              時間外は
              <a
                href="#"
                className="mx-1 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>
      </div>
    </section>
  );
}

Add to your project via shadcn CLI

npx shadcn@latest add https://designs.first-ch.com/r/tel-primary-cta.json