BLK-53Blocks
空き枠カレンダー直結CTA
予約導線を二段にしたCTA。押す前に選ばせる型で、主役は文でもボタンでもなく在庫そのもの。直近1週間の日付を列、午前・午後・夜間を行に取ったマトリクスへ空き状況(◎空きあり/○残りわずか/×満席/–受付なし)を並べ、記号の意味は表の前の凡例で先に渡す。空きのある枠だけが押せるボタンで、満席の枠は disabled にしてタブ順から外す。枠を選ぶと選択面がスケールで立ち上がり、その列の日付見出しにアンバーの罫が入り、最下段の要約が日付・曜日・時間帯を文で読み上げ、それまで灰色だった予約ボタンが活性化する。今週/翌週の切り替えつき。日付はマウント後に描いて hydration のずれを避けている。角丸・英字キッカー・矢印リンクを使わず、hairline罫線と余白だけで束ねた。日付・空き状況はすべて架空の表示サンプル。
- 追加日:
- 2026-09-06
- 依存:
- なし
- tags
- #cta #booking #reservation #availability #calendar #table #block #hairline #tabular-nums #brand #motion #no-image
プレビュー
空き枠から予約する
空きあり残りわずか満席受付なし
| 時間帯 | ––本日 | –– | –– | –– | –– | –– | –– |
|---|---|---|---|---|---|---|---|
| 午前 | |||||||
| 午後 | |||||||
| 夜間 |
表示している日付・空き状況はサンプルです。ご希望の枠が満席の場合は、あらためて日程をご相談ください。
ご希望の空き枠を選んでください。選ぶと予約に進めます。
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { Montserrat } from "next/font/google";
const montserrat = Montserrat({
weight: ["600", "700"],
subsets: ["latin"],
display: "swap",
preload: false,
});
/**
* 空き枠カレンダー直結CTA / Availability-Grid Booking CTA
* 既存のCTAは「文とボタン」または「番号の大活字」で1タップで終わる一段の導線が揃っているため、
* これは *押す前に選ばせる* 二段操作の型にした。主役は在庫そのもので、日付×時間帯のマトリクスを
* 先に読ませ、空き枠を選んで初めて予約ボタンが活性化する。既存の開催日一覧が縦1次元の「読む」表なのに対し、
* こちらは週送り・凡例・選択状態を持つ2次元の「操作する」表という違いで別物にしている。
* 日付・空き状況はすべて架空の表示サンプル。
*
* 汎用技法メモ(web-design-playbook 還流用):
* - 予約導線は「在庫の面 → 選択 → 確定ボタン」の二段にする: ボタンを最初から押せる状態で置くと、
* ユーザーは希望日時を決める前に押してしまい、次の画面で選び直しになる。ボタンは disabled で置き、
* 選択が入って初めて活性化させると、CTAの直前で日時が確定する。disabled の間も同じ寸法で描いておくと
* 活性化のときにレイアウトが動かない。
* - 空き状況のマトリクスは <table> で組む: 列見出しに日付(scope="col")、行見出しに時間帯(scope="row")を置き、
* セルの中に <button> を入れる。div のグリッドで組むと支援技術が「どの日のどの時間帯か」を読めない。
* 記号(◎○×)だけでは意味が伝わらないので、各ボタンの aria-label に日付・時間帯・空き状況を文で持たせ、
* 記号自体は aria-hidden にする。
* - 満席・受付不可のセルは disabled にしてタブ順から外す: 7日×3帯を全部フォーカス可能にするとタブが21回要る。
* 押せない枠を落とすだけで移動量が実用域まで減る。
* - 選択の面はカラーではなく transform で入れる: セル内に absolute の amber 面を敷き、scale-0 → scale-100 を
* origin-center で走らせる。背景色のアニメーションと違い motion-reduce で即時表示へ落としやすい。
* - 日付は必ずマウント後に描く: 「直近1週間」はクライアントの時計に依存するため、SSR で描くと hydration が
* 壊れる。初期描画は同じ桁数のプレースホルダ(tabular-nums の "–")にして、寸法だけ先に確定させておく。
* - 角丸・英字キッカー・末尾の「→」を使わない: 記号と罫線が情報の器なので、装飾を足すと凡例と記号の
* コントラストが埋もれる。
*/
/** 時間帯(表示サンプル) */
const BANDS = [
{ label: "午前", range: "10:00–13:00" },
{ label: "午後", range: "14:00–17:00" },
{ label: "夜間", range: "18:00–20:00" },
] as const;
type Mark = "open" | "few" | "full" | "none";
const MARK_GLYPH: Record<Mark, string> = {
open: "◎",
few: "○",
full: "×",
none: "–",
};
const MARK_TEXT: Record<Mark, string> = {
open: "空きあり",
few: "残りわずか",
full: "満席",
none: "受付なし",
};
/** 空き状況(表示サンプル)。[週][時間帯][0=当日 … 6=6日後] */
const WEEKS: readonly (readonly (readonly Mark[])[])[] = [
[
["full", "few", "open", "open", "few", "open", "none"],
["few", "open", "few", "open", "open", "full", "none"],
["full", "full", "open", "few", "open", "none", "none"],
],
[
["open", "open", "few", "open", "open", "few", "none"],
["open", "few", "open", "open", "few", "open", "none"],
["few", "open", "open", "full", "open", "none", "none"],
],
];
const WEEK_TABS = ["今週", "翌週"] as const;
const DAY_NAMES = ["日", "月", "火", "水", "木", "金", "土"];
type Selection = { week: number; band: number; day: number };
export default function CalendarSlotCta() {
const rootRef = useRef<HTMLElement>(null);
const confirmRef = useRef<HTMLParagraphElement>(null);
const [inView, setInView] = useState(false);
const [week, setWeek] = useState(0);
const [selected, setSelected] = useState<Selection | null>(null);
const [confirmed, setConfirmed] = useState<Selection | null>(null);
// 日付はクライアントの時計に依存するのでマウント後に決める
const [baseDate, setBaseDate] = useState<Date | null>(null);
useEffect(() => {
const id = requestAnimationFrame(() => setBaseDate(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();
}, []);
// 表示中の週の7日分。マウント前は null(プレースホルダを描く)
const days = useMemo(() => {
if (!baseDate) return null;
return Array.from({ length: 7 }, (_, i) => {
const d = new Date(baseDate);
d.setDate(d.getDate() + week * 7 + i);
return d;
});
}, [baseDate, week]);
const marks = WEEKS[week];
const current = selected?.week === week ? selected : null;
const selectedLabel = (() => {
if (!selected || !baseDate) return null;
const d = new Date(baseDate);
d.setDate(d.getDate() + selected.week * 7 + selected.day);
const band = BANDS[selected.band];
return `${d.getMonth() + 1}月${d.getDate()}日(${DAY_NAMES[d.getDay()]}) ${band.label} ${band.range}`;
})();
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 head = rise(0);
const legend = rise(80);
const table = rise(160);
const foot = rise(320);
function handleConfirm() {
if (!selected) return;
setConfirmed(selected);
// 完了パネルへフォーカスを移し、状態の変化を読み上げに乗せる
requestAnimationFrame(() => confirmRef.current?.focus());
}
return (
<section
ref={rootRef}
aria-labelledby="calendar-slot-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-2 border-b border-gray-200 px-5 py-3 sm:px-8 ${head.className}`}
style={head.style}
>
<div className="flex items-center gap-2">
<span aria-hidden="true" className="inline-block size-1 shrink-0 bg-amber-600" />
<h2
id="calendar-slot-cta-heading"
className="text-[13px] leading-none font-bold tracking-tight text-gray-900 sm:text-sm"
>
空き枠から予約する
</h2>
</div>
<div className="flex items-center border border-gray-200">
{WEEK_TABS.map((tab, i) => (
<button
key={tab}
type="button"
aria-pressed={week === i}
onClick={() => setWeek(i)}
className={`px-3 py-1.5 text-[11px] font-bold transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-600 ${
week === i
? "bg-gray-900 text-white"
: "bg-white text-gray-500 hover:text-gray-900"
} ${i === 1 ? "border-l border-gray-200" : ""}`}
>
{tab}
</button>
))}
</div>
</div>
{/* 凡例。記号の意味は表の前に必ず置く */}
<div
className={`flex flex-wrap items-center gap-x-4 gap-y-1 border-b border-gray-100 px-5 py-2 text-[11px] text-gray-500 sm:px-8 ${legend.className}`}
style={legend.style}
>
{(["open", "few", "full", "none"] as const).map((m) => (
<span key={m} className="flex items-center gap-1.5">
<span
aria-hidden="true"
className={`inline-block w-3 text-center text-[13px] leading-none font-bold ${
m === "open" ? "text-amber-600" : "text-gray-400"
}`}
>
{MARK_GLYPH[m]}
</span>
{MARK_TEXT[m]}
</span>
))}
</div>
{/* 空き状況のマトリクス */}
<div className={`px-3 py-4 sm:px-8 sm:py-6 ${table.className}`} style={table.style}>
<table className="w-full table-fixed border-collapse">
<caption className="sr-only">
直近1週間の日付と時間帯ごとの空き状況。空きのある枠を選ぶと予約に進めます。
</caption>
<thead>
<tr>
<th scope="col" className="w-[3.25rem] sm:w-24">
<span className="sr-only">時間帯</span>
</th>
{Array.from({ length: 7 }, (_, i) => {
const d = days?.[i];
const isToday = week === 0 && i === 0;
const isSelectedCol = current?.day === i;
return (
<th
key={i}
scope="col"
className={`border-b px-0 pb-2 text-center align-bottom transition-colors ${
isSelectedCol ? "border-amber-600" : "border-gray-200"
}`}
>
<span
className={`block text-[10px] leading-none ${
isSelectedCol ? "text-amber-700" : "text-gray-500"
}`}
>
{d ? DAY_NAMES[d.getDay()] : "–"}
</span>
<span
className={`${montserrat.className} mt-1 block text-[13px] leading-none font-semibold tabular-nums sm:text-[15px] ${
isSelectedCol || isToday ? "text-gray-900" : "text-gray-700"
}`}
>
{d ? d.getDate() : "–"}
</span>
{/* 高さを揃えるため今日以外も同じ字を置くが、読み上げからは外す */}
<span
aria-hidden={!isToday}
className={`mt-1 block text-[9px] leading-none ${
isToday ? "text-amber-700" : "text-transparent"
}`}
>
本日
</span>
</th>
);
})}
</tr>
</thead>
<tbody>
{BANDS.map((band, bi) => (
<tr key={band.label} className="border-b border-gray-100 last:border-b-0">
<th
scope="row"
className="py-2 pr-2 text-left align-middle sm:py-2.5 sm:pr-3"
>
<span className="block text-[11px] leading-tight font-bold text-gray-900 sm:text-[12.5px]">
{band.label}
</span>
<span
className={`${montserrat.className} mt-0.5 hidden text-[10.5px] leading-none tabular-nums text-gray-500 sm:block`}
>
{band.range}
</span>
</th>
{marks[bi].map((mark, di) => {
const bookable = mark === "open" || mark === "few";
const isSelected =
current?.band === bi && current?.day === di;
const d = days?.[di];
const dateText = d
? `${d.getMonth() + 1}月${d.getDate()}日`
: `${di + 1}日目`;
return (
<td key={di} className="p-0 text-center align-middle">
<button
type="button"
disabled={!bookable}
aria-pressed={isSelected}
aria-label={`${dateText} ${band.label} ${band.range} ${MARK_TEXT[mark]}`}
onClick={() => {
setConfirmed(null);
setSelected(
isSelected ? null : { week, band: bi, day: di },
);
}}
className="group relative flex h-11 w-full items-center justify-center focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-amber-600 disabled:cursor-default sm:h-12"
>
{/* 選択の面は transform で入れる(色のアニメーションにしない)。
タップ領域はセル全体のまま、見える面だけ中央の正方形にする */}
<span
aria-hidden="true"
className={`absolute top-1/2 left-1/2 size-[1.625rem] origin-center -translate-x-1/2 -translate-y-1/2 bg-amber-600 transition-transform duration-300 ease-out motion-reduce:transition-none sm:size-9 ${
isSelected ? "scale-100" : "scale-0"
}`}
/>
{/* hover は薄い面を敷くだけ。押せない枠には出さない */}
<span
aria-hidden="true"
className={`absolute top-1/2 left-1/2 size-[1.625rem] origin-center -translate-x-1/2 -translate-y-1/2 bg-amber-50 transition-transform duration-200 ease-out motion-reduce:transition-none sm:size-9 ${
bookable && !isSelected
? "scale-0 group-hover:scale-100"
: "scale-0"
}`}
/>
<span
aria-hidden="true"
className={`relative text-[15px] leading-none font-bold transition-colors sm:text-base ${
isSelected
? "text-white"
: mark === "open"
? "text-amber-600"
: mark === "few"
? "text-gray-700"
: "text-gray-300"
}`}
>
{MARK_GLYPH[mark]}
</span>
</button>
</td>
);
})}
</tr>
))}
</tbody>
</table>
<p className="mt-3 text-[10.5px] leading-[1.9] text-gray-400">
表示している日付・空き状況はサンプルです。ご希望の枠が満席の場合は、あらためて日程をご相談ください。
</p>
</div>
{/* 最下段: 選択の要約と確定ボタン。選ぶまでボタンは押せない */}
<div
className={`border-t border-gray-200 bg-gray-50 px-5 py-4 sm:px-8 sm:py-5 ${foot.className}`}
style={foot.style}
>
{confirmed ? (
<p
ref={confirmRef}
tabIndex={-1}
className="flex flex-col gap-1 text-[12.5px] leading-[1.8] text-gray-900 focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-amber-600"
>
<span className="flex items-center gap-2 text-[11px] font-bold">
<span aria-hidden="true" className="inline-block size-1 shrink-0 bg-amber-600" />
この枠でお預かりしました
</span>
<span className="text-gray-600">
{selectedLabel}
<button
type="button"
onClick={() => setConfirmed(null)}
className="ml-2 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"
>
選び直す
</button>
</span>
</p>
) : (
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between sm:gap-6">
<p
aria-live="polite"
className="min-w-0 text-[12px] leading-[1.7] sm:text-[12.5px]"
>
{selected ? (
<>
<span className="mr-2 text-[11px] font-bold text-gray-500">
選択中
</span>
<span className="font-bold text-gray-900">{selectedLabel}</span>
</>
) : (
<span className="text-gray-500">
ご希望の空き枠を選んでください。選ぶと予約に進めます。
</span>
)}
</p>
<button
type="button"
disabled={!selected}
onClick={handleConfirm}
className="w-full shrink-0 bg-amber-600 px-6 py-3 text-[13px] font-bold 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 disabled:cursor-default disabled:bg-gray-200 disabled:text-gray-400 disabled:hover:translate-y-0 motion-reduce:transition-none motion-reduce:hover:translate-y-0 sm:w-auto"
>
この枠で予約に進む
</button>
</div>
)}
</div>
</section>
);
}
shadcn CLI でプロジェクトに追加
npx shadcn@latest add https://designs.first-ch.com/r/calendar-slot-cta.json