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
お電話でのご相談
受付状況を確認しています
"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