デモ
デモを読み込めませんでした。
仕様
- 用途
- フォーム
- 動作
- 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 列にします。動きを減らす設定では、どの動きもほぼ一瞬で切り替えます。
コード
<!-- 日付も時間も本物の 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>
.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; }
}
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);
});