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

日付の入力欄を押すとカレンダーが開き、選んだ日に丸い印が滑って移る

デモ

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

仕様

用途
フォーム
動作
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 に任せるので、言語を変えても崩れません。動きを減らす設定では、開くのも印の移動もほぼ一瞬にします。予約や来店日の指定、配送日の選択、期間の絞り込みに向いています。

コード

HTML
<!-- 日付の入力欄とカレンダー。日付のマスは 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>

CSS
/* 暗い地の日付選び。印は 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; }
}

JavaScript
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));
});

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

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