First CH Designs
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

プレビュー

空き枠から予約する

空きあり残りわずか満席受付なし
直近1週間の日付と時間帯ごとの空き状況。空きのある枠を選ぶと予約に進めます。
時間帯本日
午前
午後
夜間

表示している日付・空き状況はサンプルです。ご希望の枠が満席の場合は、あらためて日程をご相談ください。

ご希望の空き枠を選んでください。選ぶと予約に進めます。

"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