Availability-Grid Booking CTA
A booking CTA built as a two-step path: the star is neither the copy nor the button but the inventory itself. A matrix puts the next seven dates across the columns and morning/afternoon/evening down the rows, filled with availability marks (open / few left / full / closed), with a legend ahead of the table so the glyphs read. Only bookable slots are buttons; full slots are disabled and drop out of the tab order. Picking a slot scales an amber tile into the cell, rules the column header in amber, spells the date, weekday and time band out in the summary bar, and only then activates the previously greyed-out confirm button. A this-week / next-week toggle sits at the top, and dates are painted after mount so hydration never mismatches. No rounded corners, no Latin kicker, no arrow links — hairline rules and spacing do the binding. All dates and availability are display samples.
- Added:
- 2026-09-06
- Dependencies:
- None
- tags
- #cta #booking #reservation #availability #calendar #table #block #hairline #tabular-nums #brand #motion #no-image
Preview
空き枠から予約する
| 時間帯 | ––本日 | –– | –– | –– | –– | –– | –– |
|---|---|---|---|---|---|---|---|
| 午前 | |||||||
| 午後 | |||||||
| 夜間 |
表示している日付・空き状況はサンプルです。ご希望の枠が満席の場合は、あらためて日程をご相談ください。
ご希望の空き枠を選んでください。選ぶと予約に進めます。
"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>
);
}
Add to your project via shadcn CLI
npx shadcn@latest add https://designs.first-ch.com/r/calendar-slot-cta.json