本文へスキップ
HELLO!
HIGASHIYUKI®.COM
0   /   100
フォーム

予約の時間枠が並び、選んだ枠へ印が移り、埋まった枠は選べない形で示される

デモ

操作:クリック。 「さわってみる」を押すと自動再生が止まり、マウスで動かせます。

仕様

用途
フォーム
動作
DOM
種類
レシピ
動作確認
three 0.186.0・gsap 3.15.0
公開日

しくみと調整

日付のタブの下に 30 分ごとの時間の枠を並べ、選んだ枠には墨色の板を載せます。別の枠を選ぶと、板が前の枠から滑って移り、下の「ご予約日時」の文が上へ抜けて、次の文が下から入れ替わります。板は、枠の並びを明暗反転で描いた写しを、選んだ枠の形に clip-path で切り抜いたものです。滑る途中で板にかかった枠の文字は、板の縁でちょうど白と墨に割れて見えます。

日付を替えると、枠が左上から 0.035 秒ずつ遅れて上へ抜け、新しい日の空き具合で下から入れ替わります(前の日へ戻るときは下へ抜けます)。埋まった枠は薄い斜線の地に「満」を添え、押せない形のカーソルにします。選んでいた時刻が新しい日も空いていれば、板はそのまま残ります。埋まっていれば、その枠が抜け始めると同時に板が枠の中央へ縮んで消え、文が「時間をお選びください」に、ボタンが押せない灰色に変わります。

板の動きは、0.4 秒に「離れた枠の数 × 0.08 秒」を足した長さの power3.inOut です。隣の枠へはきびきびと、斜めに離れた枠へは少しゆっくり移るので、どこから来たかが目で追えます。確認の文は、前の文が 0.18 秒で抜けきってから次の文を 0.3 秒で入れます。同じ場所で 2 つの文が重なると、途中で読めなくなるためです。

日付も時間も本物の radio で、クリックでも矢印キーでも選べ、フォームでそのまま送れます。埋まった時刻は、日付を替えた時点ですぐ disabled にし、見た目の斜線だけを入れ替えに合わせて替えます。入れ替わりの途中でも、埋まった枠を選べないようにするためです。disabled の radio は矢印キーで飛ばされ、読み上げでは「10:00(満席)」と名前を添えて伝えます。写しの並びは radio を取り除いてから置き、inert で読み上げとタブ移動から外します。このデモでは日付と時間の枠をクリックで選べ、キーボードの操作はコードのほうの本物の radio が受けます。

縦に並んだ選択肢の間で印が伸びて移るラジオや、カレンダーのマスに丸い印が付く日付の入力と違い、埋まった枠が混ざる格子の中を、板が斜めにも滑るのが特徴です。美容室や整体、面談、試乗などの予約画面に向いています。空き具合は日付の radio の data-full に書くので、予約の仕組みから受け取った時刻をそこへ入れれば動きます。列の数は CSS で決め、幅の狭い画面では 3 列にします。動きを減らす設定では、どの動きもほぼ一瞬で切り替えます。

コード

HTML
<!-- 日付も時間も本物の radio。クリックでも矢印キーでも選べ、フォームでそのまま送れる。埋まった時刻は data-full に空白で区切って書く -->
<form class="booking" data-empty="時間をお選びください" data-full-label="満席">
  <fieldset class="booking__dates">
    <legend class="booking__month"><span class="sr-only">日付 </span>2026年10月</legend>
    <div class="dates">
      <label class="date"><input type="radio" name="date" value="2026-10-02" aria-label="10月2日(金)" data-full="11:30 13:00 16:30"><span class="date__week">金</span><span class="date__day">2日</span></label>
      <label class="date"><input type="radio" name="date" value="2026-10-03" aria-label="10月3日(土)" data-full="10:00 13:30 15:00 16:00" checked><span class="date__week">土</span><span class="date__day">3日</span></label>
      <label class="date"><input type="radio" name="date" value="2026-10-04" aria-label="10月4日(日)" data-full="11:00 11:30 14:00 15:30 16:30"><span class="date__week">日</span><span class="date__day">4日</span></label>
      <label class="date"><input type="radio" name="date" value="2026-10-05" aria-label="10月5日(月)" data-full="10:00"><span class="date__week">月</span><span class="date__day">5日</span></label>
      <label class="date"><input type="radio" name="date" value="2026-10-06" aria-label="10月6日(火)" data-full="13:30 14:00 14:30 15:00"><span class="date__week">火</span><span class="date__day">6日</span></label>
      <span class="dates__bar" aria-hidden="true"></span>
    </div>
  </fieldset>

  <fieldset class="booking__times">
    <legend class="sr-only">時間</legend>
    <div class="slots">
      <label class="slot"><input type="radio" name="time" value="10:00" required><span class="slot__face"><span>10:00</span><span class="slot__full" aria-hidden="true">満</span></span></label>
      <label class="slot"><input type="radio" name="time" value="10:30" required><span class="slot__face"><span>10:30</span><span class="slot__full" aria-hidden="true">満</span></span></label>
      <label class="slot"><input type="radio" name="time" value="11:00" required checked><span class="slot__face"><span>11:00</span><span class="slot__full" aria-hidden="true">満</span></span></label>
      <label class="slot"><input type="radio" name="time" value="11:30" required><span class="slot__face"><span>11:30</span><span class="slot__full" aria-hidden="true">満</span></span></label>
      <label class="slot"><input type="radio" name="time" value="13:00" required><span class="slot__face"><span>13:00</span><span class="slot__full" aria-hidden="true">満</span></span></label>
      <label class="slot"><input type="radio" name="time" value="13:30" required><span class="slot__face"><span>13:30</span><span class="slot__full" aria-hidden="true">満</span></span></label>
      <label class="slot"><input type="radio" name="time" value="14:00" required><span class="slot__face"><span>14:00</span><span class="slot__full" aria-hidden="true">満</span></span></label>
      <label class="slot"><input type="radio" name="time" value="14:30" required><span class="slot__face"><span>14:30</span><span class="slot__full" aria-hidden="true">満</span></span></label>
      <label class="slot"><input type="radio" name="time" value="15:00" required><span class="slot__face"><span>15:00</span><span class="slot__full" aria-hidden="true">満</span></span></label>
      <label class="slot"><input type="radio" name="time" value="15:30" required><span class="slot__face"><span>15:30</span><span class="slot__full" aria-hidden="true">満</span></span></label>
      <label class="slot"><input type="radio" name="time" value="16:00" required><span class="slot__face"><span>16:00</span><span class="slot__full" aria-hidden="true">満</span></span></label>
      <label class="slot"><input type="radio" name="time" value="16:30" required><span class="slot__face"><span>16:30</span><span class="slot__full" aria-hidden="true">満</span></span></label>
    </div>
  </fieldset>

  <div class="booking__foot">
    <p class="booking__confirm">
      <span class="booking__label">ご予約日時</span>
      <span class="booking__value" aria-live="polite"><span class="booking__text">10月3日(土)11:00〜</span></span>
    </p>
    <button class="booking__submit" type="submit">この日時で予約する</button>
  </div>
</form>

CSS
.booking {
  --ink: #262626;                 /* 文字・板・選んだ日の下線 */
  --mute: #898989;                /* 選んでいない日・埋まった枠の文字 */
  --mute-small: #6b6b6b;          /* 小さい補足 */
  --line: rgba(38, 38, 38, 0.12); /* 枠の縁 */
  --surface: #ffffff;             /* 枠の地 */
  --off: #c8c7c3;                 /* 押せないボタンの地 */
  --radius: 4px;
  --hatch: repeating-linear-gradient(135deg, rgba(38, 38, 38, 0.1) 0 1.5px, transparent 1.5px 7px), #f2f1ed; /* 埋まった枠は地の色に沈めて斜線 */
  max-width: 544px;
  color: var(--ink);
}

.booking fieldset { min-width: 0; margin: 0; padding: 0; border: 0; }
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.booking__month { margin-bottom: 16px; padding: 0; font-size: 13px; font-weight: 600; letter-spacing: 0.06em; color: var(--mute-small); }

/* 本物の radio は見えなくするだけ(display: none にすると、キーボードで選べなくなる) */
.booking input[type="radio"] { position: absolute; width: 1px; height: 1px; margin: 0; opacity: 0; pointer-events: none; }

.dates { position: relative; display: grid; grid-template-columns: repeat(5, 1fr); box-shadow: inset 0 -1px 0 var(--line); }
.date { display: grid; justify-items: center; gap: 8px; padding: 12px 0 10px; color: var(--mute); cursor: pointer; transition: color 0.25s; }
.date__week { font-size: 12px; font-weight: 600; letter-spacing: 0.06em; line-height: 1; color: var(--mute-small); transition: color 0.25s; }
.date__day { font-size: 20px; font-weight: 700; letter-spacing: 0.02em; line-height: 1.3; }
.date:has(:checked),
.date:has(:checked) .date__week { color: var(--ink); }
.date:has(:focus-visible) { outline: 2px solid var(--ink); outline-offset: -2px; border-radius: var(--radius); }

/* 選んだ日の下線。幅はタブと同じにし、左右の padding の分だけ内側に描く */
.dates__bar { position: absolute; left: 0; bottom: 0; height: 2px; padding: 0 12px; box-sizing: border-box; background: var(--ink); background-clip: content-box; }

.booking .booking__times { position: relative; margin-top: 24px; }
.slots { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; }

/* 枠は overflow: hidden の窓。中の面を上下に送って入れ替え、縁は面の上に重ねて引く */
.slot { position: relative; display: block; height: 52px; border-radius: var(--radius); background: var(--surface); overflow: hidden; cursor: pointer; }
.slot::after { content: ""; position: absolute; inset: 0; border-radius: inherit; box-shadow: inset 0 0 0 1px var(--line); pointer-events: none; }
.slot:not(.is-full):hover::after { box-shadow: inset 0 0 0 1px var(--ink); }
.slot:has(:focus-visible) { outline: 2px solid var(--ink); outline-offset: 3px; }
.slot__face { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; gap: 6px; font-size: 16px; font-weight: 600; letter-spacing: 0.04em; font-variant-numeric: tabular-nums; }
.slot__full { display: none; font-size: 12px; font-weight: 700; letter-spacing: 0.06em; color: var(--mute-small); }

/* 埋まった枠: 薄い斜線の地に「満」。押せない形のカーソルにする */
.slot.is-full { cursor: not-allowed; }
.slot.is-full .slot__face { background: var(--hatch); color: var(--mute); }
.slot.is-full .slot__full { display: block; }

/* 板: 枠の並びを明暗反転で描いた写しを、JS が選んだ枠の形に切り抜く */
.slots--invert { position: absolute; inset: 0; background: var(--ink); color: #ffffff; pointer-events: none; clip-path: inset(50%); }
.slots--invert .slot { background: none; }
.slots--invert .slot::after { content: none; }
.slots--invert .slot.is-full .slot__face { background: repeating-linear-gradient(135deg, rgba(255, 255, 255, 0.24) 0 1.5px, transparent 1.5px 7px); color: rgba(255, 255, 255, 0.72); }
.slots--invert .slot__full { color: #ffffff; }

.booking__foot { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 16px 24px; margin-top: 32px; }
.booking__confirm { display: grid; gap: 6px; margin: 0; }
.booking__label { font-size: 12px; font-weight: 600; letter-spacing: 0.06em; line-height: 1; color: var(--mute-small); }
.booking__value { display: block; height: 28px; overflow: hidden; }
.booking__text { display: block; font-size: 20px; font-weight: 700; line-height: 28px; letter-spacing: 0.04em; font-variant-numeric: tabular-nums; white-space: nowrap; }
.booking__text.is-empty { font-size: 16px; color: var(--mute); }

.booking__submit {
  height: 48px;
  padding: 0 32px;
  border: 0;
  border-radius: 999px;
  background: var(--ink);
  color: #ffffff;
  font: inherit;
  font-size: 15px;
  font-weight: 600;
  letter-spacing: 0.04em;
  cursor: pointer;
  transition: background-color 0.3s;
}

.booking__submit[aria-disabled="true"] { background: var(--off); cursor: not-allowed; }
.booking__submit:focus-visible { outline: 2px solid var(--ink); outline-offset: 3px; }

/* 狭い画面では 3 列にする(4 列だと「10:00 満」が枠からはみ出す) */
@media (max-width: 480px) {
  .slots { grid-template-columns: repeat(3, 1fr); }
}

@media (prefers-reduced-motion: reduce) {
  .date,
  .date__week,
  .booking__submit { transition: none; }
}

JavaScript
import { gsap } from "gsap";

// 動きを減らす設定では、どの動きもほぼ一瞬にする(長さ 0 にしないのは、並べたトゥイーンの順序を崩さないため)
const speed = window.matchMedia("(prefers-reduced-motion: reduce)").matches ? 0.02 : 1;

document.querySelectorAll(".booking").forEach((form) => {
  const dates = [...form.querySelectorAll('input[name="date"]')];
  const times = [...form.querySelectorAll('input[name="time"]')];
  const [grid, bar, text, submit] = [".slots", ".dates__bar", ".booking__text", ".booking__submit"].map((s) => form.querySelector(s));
  // 板は、枠の並びを明暗反転で描いた写しを、選んだ枠の形に切り抜いて作る。滑る途中は、板の縁で文字が白と墨に割れる
  const invert = grid.cloneNode(true);
  invert.querySelectorAll("input").forEach((input) => input.remove()); // 同じ name の radio を 2 つにしない
  invert.classList.add("slots--invert");
  invert.inert = true; // 読み上げとタブ移動から外す
  grid.after(invert);
  const pairs = times.map((input, i) => [input.closest(".slot"), invert.children[i]]);
  const faces = pairs.map((slots) => slots.map((slot) => slot.querySelector(".slot__face")));
  const plate = { x: 0, y: 0, w: 0, h: 0, g: 0 }; // 並びの中での板の位置と大きさ。g が 0 で枠の中央へ縮んで消える
  let date = dates.find((input) => input.checked);
  let time = times.find((input) => input.checked) ?? null;
  let plateTween, swap, flips = [];
  const clip = () => {
    const [dx, dy] = [(plate.w * (1 - plate.g)) / 2, (plate.h * (1 - plate.g)) / 2];
    const [right, bottom] = [invert.offsetWidth - plate.x - plate.w + dx, invert.offsetHeight - plate.y - plate.h + dy];
    invert.style.clipPath = plate.g <= 0 ? "inset(50%)" : `inset(${plate.y + dy}px ${right}px ${bottom}px ${plate.x + dx}px round var(--radius))`;
  };
  const tweenPlate = ({ duration, delay = 0, ...vars }) => {
    plateTween?.kill();
    plateTween = gsap.to(plate, { ...vars, duration: duration * speed, delay: delay * speed, onUpdate: clip });
  };
  const rectOf = (input) => {
    const [box, base] = [input.closest(".slot").getBoundingClientRect(), grid.getBoundingClientRect()];
    return { x: box.left - base.left, y: box.top - base.top, w: box.width, h: box.height };
  };
  const placeBar = (duration) => {
    const tab = date.closest(".date");
    gsap.to(bar, { x: tab.offsetLeft, width: tab.offsetWidth, duration: duration * speed, ease: "power3.inOut", overwrite: true });
  };
  // 埋まった時刻は、日付を替えたらすぐ押せなくする(見た目の斜線は、枠が入れ替わるときに替える)
  const fullOf = () => date.dataset.full.split(" ");
  const setFull = (i, full) => pairs[i].forEach((slot) => slot.classList.toggle("is-full", full));
  const applyDate = () => times.forEach((input) => {
    input.disabled = fullOf().includes(input.value);
    input.toggleAttribute("aria-label", input.disabled); // 読み上げでは「10:00(満席)」と伝える
    if (input.disabled) input.setAttribute("aria-label", `${input.value}(${form.dataset.fullLabel})`);
  });
  // 確認の文: 前の文を上へ抜き、抜けきってから次の文を下から入れる
  const write = () => {
    text.textContent = time ? `${date.getAttribute("aria-label")}${time.value}〜` : form.dataset.empty;
    text.classList.toggle("is-empty", !time);
    submit.setAttribute("aria-disabled", String(!time));
  };
  const say = () => {
    swap?.kill();
    swap = gsap.timeline()
      .to(text, { yPercent: -100, duration: 0.18 * speed, ease: "power2.in" }, 0.02 * speed)
      .call(write)
      .fromTo(text, { yPercent: 100 }, { yPercent: 0, duration: 0.3 * speed, ease: "power3.out" }, 0.2 * speed);
  };
  times.forEach((input, i) => input.addEventListener("change", () => {
    const cols = getComputedStyle(grid).gridTemplateColumns.split(" ").length;
    const a = times.indexOf(time);
    const d = Math.hypot((a % cols) - (i % cols), Math.floor(a / cols) - Math.floor(i / cols));
    if (!time) Object.assign(plate, rectOf(input), { g: 0 }); // 選んでいなかったときは、押した枠の中央から広げる
    tweenPlate(time ? { ...rectOf(input), g: 1, duration: 0.4 + 0.08 * d, ease: "power3.inOut" } : { g: 1, duration: 0.3, ease: "power3.out" });
    time = input;
    say();
  }));

  dates.forEach((input) => input.addEventListener("change", () => {
    const dir = Math.sign(dates.indexOf(input) - dates.indexOf(date)); // 後の日へは上へ、前の日へは下へ送る
    date = input;
    applyDate();
    placeBar(0.35);
    const full = fullOf();
    flips.forEach((flip) => flip.kill());
    flips = faces.map((pair, i) => gsap.timeline({ delay: (0.08 + 0.035 * i) * speed }) // 左上から順に入れ替える
      .to(pair, { yPercent: -100 * dir, duration: 0.16 * speed, ease: "power2.in" })
      .call(() => setFull(i, full.includes(times[i].value)))
      .fromTo(pair, { yPercent: 100 * dir }, { yPercent: 0, duration: 0.24 * speed, ease: "power3.out" }));
    if (time?.disabled) {
      // 選んでいた時刻が埋まっていたら、その枠が抜け始めると同時に、板を枠の中央へ縮めて外す
      tweenPlate({ g: 0, duration: 0.2, delay: 0.08 + 0.035 * times.indexOf(time), ease: "power2.in" });
      time = (time.checked = false) || null;
    }
    say();
  }));
  submit.addEventListener("click", (event) => time || event.preventDefault()); // aria-disabled だけでは送信は止まらない
  // 最初の状態と、画面の幅や書体で枠の大きさが変わったとき: 動かさずに置き直す
  applyDate();
  times.forEach((input, i) => setFull(i, input.disabled));
  if (time?.disabled) time = (time.checked = false) || null;
  write();
  new ResizeObserver(() => {
    placeBar(0);
    if (time) Object.assign(plate, rectOf(time), { g: 1 });
    clip();
  }).observe(grid);
});

インタラクションデザインについて、
お気軽にご相談ください。

ここに並べているのは、Web サイトの中で動く UI のサンプルです。新しく作るサイトに組み込むことも、いま公開中のサイトに、ボタンやヒーロー、スクロールの演出だけを足すこともできます。設計から実装までの進め方はWeb サイト制作、端末ごとの見え方や表示速度の考え方はレスポンシブウェブサイトのページにまとめています。映像として作る動きをお探しの場合はモーショングラフィックスをご覧ください。