デモ
デモを読み込めませんでした。
仕様
- 用途
- フォーム
- 動作
- DOM
- 種類
- レシピ
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
欄を押すとカレンダーが下に開き、日付のマスが左上から順に現れます。選んだ日を示す丸い印はページに1つだけ置いてあり、選び直すたびに、前の日から次の日へ滑って移ります。印は横と縦を別の時間で動かすので、まっすぐ斜めに飛ぶのではなく、軽く曲がった線を描いて流れ込みます。
横は0.42秒の power3.inOut、縦は0.06秒遅らせて0.5秒の power2.inOut で動かし、その間に印を0.8倍まですぼめてから戻します。日付のマスは10px上から0.012秒ずつずれて現れ、パネルの高さは0.35秒で伸びます。月の送りは0.45秒で、日付の並びが横に入れ替わります。
印の行き先は、そのマスを測って決めます。hidden のまま測ると大きさが0と読めて、行き先が半径のぶんだけずれるので、隠れているものは、いったん出してから測ります。印が動いているあいだに数字を地の色へ反転させると、暗い面の上で読めなくなるので、色を変えるのは印が着くころにします。
欄は押せるボタンにして、開いているかどうかを aria-expanded で伝えます。日付のマスも1つずつがボタンで、読み上げには「2026年10月8日」と日付をそのまま伝え、選んでいる日は aria-current の date で示します。Escで閉じて欄へ焦点を戻し、矢印キーで前後の日・前後の週へ移れます。送る値は、隠した入力に数字の日付で入れます。
「2026-10-08」のような文字列をそのまま日付にすると世界時と見なされ、時差によっては前の日になります。年・月・日に分けて組み立てます。画面に出す書き方は Intl.DateTimeFormat に任せるので、言語を変えても崩れません。動きを減らす設定では、開くのも印の移動もほぼ一瞬にします。予約や来店日の指定、配送日の選択、期間の絞り込みに向いています。
コード
<!-- 日付の入力欄とカレンダー。日付のマスは JS が月から組み立て、印は 1 つだけ置いて動かす -->
<form class="booking" action="/booking/" method="post">
<div class="picker">
<span class="picker__label" id="visit-label">ご来店日</span>
<button class="picker__field" type="button" aria-haspopup="dialog" aria-expanded="false" aria-labelledby="visit-label visit-value">
<span class="picker__value" id="visit-value">2026年10月8日</span>
<svg class="picker__icon" viewBox="0 0 16 16" aria-hidden="true"><path d="M5 2.5v2.5M11 2.5v2.5M2.5 7h11M3 4h10v9H3z" /></svg>
</button>
<div class="picker__panel" role="dialog" aria-modal="false" aria-labelledby="visit-month" hidden>
<div class="picker__head">
<button class="picker__nav" type="button" data-move="-1">
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M10 3l-5 5 5 5" /></svg><span class="picker__sr">前の月</span>
</button>
<h2 class="picker__month" id="visit-month">2026年10月</h2>
<button class="picker__nav" type="button" data-move="1">
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M6 3l5 5-5 5" /></svg><span class="picker__sr">次の月</span>
</button>
</div>
<div class="picker__week" aria-hidden="true">
<span>日</span><span>月</span><span>火</span><span>水</span><span>木</span><span>金</span><span>土</span>
</div>
<!-- 月の並びは、この枠の中で横に入れ替える -->
<div class="picker__view">
<div class="picker__track"><span class="picker__mark" aria-hidden="true"></span></div>
</div>
</div>
<input type="hidden" name="visit" value="2026-10-08">
</div>
</form>
/* 暗い地の日付選び。印は 1 つだけ置き、選んだマスへ動かす */
.booking {
--ground: #111111; /* フォームの地 */
--ink: #eeece6; /* 文字と印 */
--label: #a8a6a0; /* 欄の名前・曜日(16px 未満の補足) */
--surface: #262626; /* カレンダーの面 */
--line: rgba(238, 236, 230, 0.14); /* 休みの枠と面の縁 */
--cell: 44px; /* 日付のマスの幅 */
padding: 32px;
background: var(--ground);
color: var(--ink);
}
.picker { position: relative; max-width: 544px; }
.picker__label { display: block; margin-bottom: 8px; color: var(--label); font-size: 13px; font-weight: 600; letter-spacing: 0.06em; }
.picker__field {
display: flex; align-items: center; justify-content: space-between; gap: 16px;
box-sizing: border-box; width: 100%; height: 56px; padding: 0 16px;
border: 0; border-radius: 4px; box-shadow: inset 0 0 0 1px var(--line);
background: none; color: inherit; font: inherit; font-size: 18px; letter-spacing: 0.04em; cursor: pointer;
}
.picker__field[aria-expanded="true"] { box-shadow: inset 0 0 0 1px var(--ink); }
.picker__value { font-variant-numeric: tabular-nums; }
.picker__icon { flex: none; width: 20px; height: 20px; fill: none; stroke: currentColor; stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round; }
.picker__panel {
position: absolute; z-index: 1; margin-top: 8px; padding: 16px; overflow: clip;
border-radius: 4px; background: var(--surface); box-shadow: inset 0 0 0 1px var(--line);
}
.picker__panel[hidden] { display: none; }
.picker__head { display: flex; align-items: center; justify-content: space-between; height: 40px; }
.picker__month { margin: 0; width: 180px; text-align: center; font-size: 17px; font-weight: 700; letter-spacing: 0.02em; font-variant-numeric: tabular-nums; }
.picker__nav {
display: grid; place-items: center; width: 32px; height: 32px; padding: 0;
border: 0; border-radius: 50%; background: none; color: inherit; cursor: pointer;
}
.picker__nav svg { width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
.picker__sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
.picker__week { display: grid; grid-template-columns: repeat(7, var(--cell)); margin-top: 4px; }
.picker__week span { color: var(--label); font-size: 12px; font-weight: 700; line-height: 24px; text-align: center; letter-spacing: 0.04em; }
/* 月の並びを入れ替える枠。はみ出しは clip で隠す(hidden だと、焦点が外の月へ移ったとき枠が横にずれる) */
.picker__view { position: relative; overflow: clip; width: calc(var(--cell) * 7); }
.picker__track { position: relative; height: 100%; }
.picker__grid { position: absolute; top: 0; width: 100%; display: grid; grid-template-columns: repeat(7, var(--cell)); grid-auto-rows: 40px; }
.picker__day {
display: grid; place-items: center; padding: 0;
border: 0; border-radius: 50%; background: none;
color: var(--ink); font: inherit; font-size: 15px; font-weight: 500; font-variant-numeric: tabular-nums;
cursor: pointer; transition: color 0.2s;
}
/* 印の下に入ったマスの数字は、地の色へ反転する */
.picker__day[aria-current] { color: var(--ground); }
.picker__mark {
position: absolute; left: 0; top: 0; width: 36px; height: 36px;
border-radius: 50%; background: var(--ink); pointer-events: none;
}
.picker__mark[hidden] { display: none; }
.picker :focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; }
@media (prefers-reduced-motion: reduce) { .picker__day { transition-duration: 0.01s; } }
@media (max-width: 480px) {
.booking { padding: 24px 16px; }
.picker__field { font-size: 16px; }
.picker { --cell: 40px; }
.picker__month { width: 140px; font-size: 15px; }
}
import { gsap } from "gsap";
// 動きを減らす設定では、ほぼ一瞬で切り替える
const T = window.matchMedia("(prefers-reduced-motion: reduce)").matches ? 0.02 : 1;
const dayText = new Intl.DateTimeFormat("ja-JP", { year: "numeric", month: "long", day: "numeric" });
const monthText = new Intl.DateTimeFormat("ja-JP", { year: "numeric", month: "long" });
const iso = (d) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
// 「2026-10-08」を new Date にそのまま渡すと UTC と見なされ、地域によっては前日になる。数字に分けて作る
const parse = (text) => new Date(...text.split("-").map((v, i) => (i === 1 ? Number(v) - 1 : Number(v))));
document.querySelectorAll(".picker").forEach((picker) => {
const el = (sel) => picker.querySelector(sel);
const field = el(".picker__field"), value = el(".picker__value"), panel = el(".picker__panel");
const view = el(".picker__view"), track = el(".picker__track"), mark = el(".picker__mark");
const title = el(".picker__month"), store = el("input[type=hidden]");
let picked = parse(store.value), month = new Date(picked.getFullYear(), picked.getMonth(), 1), grid;
// 印を、そのマスの中心へ動かす。横と縦で時間を変え、途中で少しすぼめると、流れ込むように見える
const place = (cell, animate) => {
mark.hidden = false; // 隠したままでは大きさを測れない(0 と読めて、行き先が半径ぶんずれる)
const a = cell.getBoundingClientRect(), b = track.getBoundingClientRect();
const x = a.left - b.left + (a.width - mark.offsetWidth) / 2;
const y = a.top - b.top + (a.height - mark.offsetHeight) / 2;
if (!animate) return gsap.set(mark, { x, y, scale: 1 });
gsap.to(mark, { x, duration: 0.42 * T, ease: "power3.inOut" });
gsap.to(mark, { y, duration: 0.5 * T, delay: 0.06 * T, ease: "power2.inOut" });
gsap.to(mark, { keyframes: [{ scale: 0.8, duration: 0.26 * T }, { scale: 1, duration: 0.3 * T }], ease: "power2.inOut" });
};
const choose = (date, cell) => {
picked = date;
store.value = iso(date); // フォームで送るのは数字の日付
value.textContent = dayText.format(date);
grid.querySelectorAll("[aria-current]").forEach((old) => old.removeAttribute("aria-current"));
place(cell, true);
// 数字を地の色へ反転させるのは、印が着くころ(動いている間に反転すると、暗い面の上で読めなくなる)
gsap.delayedCall(0.35 * T, () => cell.setAttribute("aria-current", "date"));
};
// 1 か月ぶんのマスを組み立てる(曜日の列に置く)
const build = (first) => {
const box = document.createElement("div");
box.className = "picker__grid";
const start = new Date(first.getFullYear(), first.getMonth(), 1).getDay(), days = new Date(first.getFullYear(), first.getMonth() + 1, 0).getDate();
for (let d = 1; d <= days; d++) {
const date = new Date(first.getFullYear(), first.getMonth(), d);
const cell = Object.assign(document.createElement("button"), { type: "button", className: "picker__day", textContent: String(d) });
cell.style.gridArea = `${Math.floor((start + d - 1) / 7) + 1} / ${((start + d - 1) % 7) + 1}`;
cell.setAttribute("aria-label", dayText.format(date));
if (iso(date) === iso(picked)) cell.setAttribute("aria-current", "date");
cell.addEventListener("click", () => choose(date, cell));
box.append(cell);
}
return box;
};
// 月を送る。新しい並びを横に置いてから帯ごと滑らせ、着いたら古い並びを外す
const turn = (step) => {
const next = new Date(month.getFullYear(), month.getMonth() + step, 1);
const box = build(next);
box.style.left = `${step * 100}%`;
track.append(box);
title.textContent = monthText.format(next);
const old = grid;
[grid, month] = [box, next];
mark.hidden = true;
gsap.to(view, { height: box.offsetHeight, duration: 0.45 * T, ease: "power3.inOut" });
gsap.to(track, { x: -step * view.offsetWidth, duration: 0.45 * T, ease: "power3.inOut", onComplete: () => {
old.remove();
box.style.left = "0";
gsap.set(track, { x: 0 }); // 帯を戻し、新しい並びを枠の中へ置き直す
if (box.querySelector("[aria-current]")) place(box.querySelector("[aria-current]"), false);
} });
};
const open = (want) => {
field.setAttribute("aria-expanded", String(want));
if (!want) return gsap.to(panel, { height: 0, duration: 0.3 * T, ease: "power3.inOut", onComplete: () => (panel.hidden = true) });
panel.hidden = false;
gsap.fromTo(panel, { height: 0 }, { height: "auto", duration: 0.35 * T, ease: "power3.out" });
// マスは左上から順に現れる
gsap.fromTo(grid.children, { opacity: 0, y: 10 }, { opacity: 1, y: 0, duration: 0.4 * T, stagger: 0.012 * T, ease: "power3.out" });
(grid.querySelector("[aria-current]") ?? grid.firstElementChild).focus({ preventScroll: true });
};
grid = build(month);
track.append(grid);
panel.hidden = false; // 隠れたままでは寸法を測れないので、組み立てのあいだだけ出す
view.style.height = `${grid.offsetHeight}px`;
place(grid.querySelector("[aria-current]"), false);
panel.hidden = true;
field.addEventListener("click", () => open(panel.hidden));
picker.querySelectorAll(".picker__nav").forEach((nav) => nav.addEventListener("click", () => turn(Number(nav.dataset.move))));
// Esc で閉じて欄へ戻り、矢印キーで前後の日・前後の週へ焦点を移す
panel.addEventListener("keydown", (event) => {
const days = [...grid.children], at = days.indexOf(document.activeElement);
const step = { ArrowLeft: -1, ArrowRight: 1, ArrowUp: -7, ArrowDown: 7 }[event.key];
if (event.key === "Escape") { open(false); field.focus(); return; }
if (!step || at < 0) return;
event.preventDefault();
days[Math.min(days.length - 1, Math.max(0, at + step))].focus();
});
document.addEventListener("click", (event) => !panel.hidden && !picker.contains(event.target) && open(false));
});