本文へスキップ
HELLO!
HIGASHIYUKI®.COM
0   /   100
アイコン

日めくりのカレンダーのアイコンがめくれて、日付が次の日に進む

デモ

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

仕様

用途
アイコン
動作
DOM
種類
レシピ
動作確認
three 0.186.0・gsap 3.15.0
公開日

しくみと調整

綴じ輪の付いた日めくりのカレンダーを押すと、いちばん上の紙が綴じ目を軸に手前へめくれ、ちぎれて落ちます。下の紙には押した瞬間から次の日の日付が出ていて、月が替わる日は上の帯の月も替わります。押し続けると 0.45 秒後から 0.2 秒ごとに早くめくれ、「今日に戻る」を押すと、今日の紙が落ちたときと逆の道筋で舞い戻って重なります。

めくる紙は、上の紙の写しを重ねて動かします。前半 0.24 秒で綴じ目を軸に手前へ 64° 起こし(power2.in)、後半 0.42 秒で紙の高さの 1.1 倍だけ落としながら 84° まで倒して消します。落ちるのを power2.in にすると、重さで離れていくように見えます。紙は傾くほど暗くし、起きているあいだは下の紙の上のほうに陰を落とすので、どちらが手前かが読み取れます。

回す軸は紙の上の辺なので、transformOrigin を上の辺の中央に置き、遠近は紙の高さの 3 倍にします(transformPerspective)。遠近が弱いと、手前へ起きるのではなく縦につぶれるだけに見えます。何枚も続けてめくるときは、先にめくった紙ほど上に重ねます。後の紙を上にすると、落ちている紙に次の紙がかぶり、めくった順番が逆に見えます。

マウスや指では、押した瞬間(pointerdown)にめくり、押し続けると繰り返します。続けて届く click で 2 枚めくらないよう、click はキーボードと支援技術の「押す」(detail が 0)だけで受けます。日付は Date で進め、月と曜日は Intl.DateTimeFormat で書くので、月末や年の替わり目もそのまま越えます。今の日付は読み上げに届く領域に書き、押し続けているあいだは、離したときに 1 回だけ書きます。動きを減らす設定では、めくらずに日付だけを替えます。

文字を上下に割った札を 1 字ずつ回す盤面や、保存の印が満ちるアイコンと違い、紙がまるごと 1 枚ずつ手前へ離れていくので、日が進んだことが形で伝わります。予約や日付の選択の「次の日」、日替わりのお知らせ、カウントダウンの入口のアイコンに向いています。紙の大きさは幅の変数 1 つで決まり、ほかの寸法はその比で決まります。

コード

HTML
<!-- 日めくりのカレンダー。押すと 1 日進み、押し続けると早くめくれる。日付はスクリプトが今日に合わせて書き直す -->
<div class="daypad">
  <button class="daypad__pad" type="button" aria-label="日付を1日進める" aria-describedby="daypad-date">
    <span class="daypad__page" aria-hidden="true">
      <span class="daypad__month">9月</span>
      <span class="daypad__day">30</span>
      <span class="daypad__week">水</span>
    </span>
    <span class="daypad__ring" aria-hidden="true"></span>
    <span class="daypad__ring" aria-hidden="true"></span>
  </button>
  <!-- 今の日付は読み上げにも届ける(押し続けている間は、離したときに 1 回だけ書く) -->
  <p class="daypad__status" id="daypad-date" role="status">9月30日水曜日</p>
  <button class="daypad__today" type="button">今日に戻る</button>
</div>

CSS
.daypad {
  --ink: #262626;     /* 線・数字・綴じ輪 */
  --band: #4a5b66;    /* 上の帯 */
  --band-ink: #ffffff;
  --surface: #ffffff; /* 紙 */
  --mute: #6b6b6b;    /* 曜日 */
  --w: 72px;          /* 紙の幅。ほかの寸法はこの比で決まる(紙は 72 × 80) */
  display: inline-flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 32px;
  color: var(--ink);
}

.daypad__pad {
  position: relative;
  width: var(--w);
  height: calc(var(--w) * 80 / 72);
  margin: calc(var(--w) / 12) 0 calc(var(--w) * 5 / 72);
  padding: 0;
  border: 0;
  border-radius: calc(var(--w) / 9);
  background: none;
  color: inherit;
  font-family: inherit;
  cursor: pointer;
  user-select: none;
  -webkit-user-select: none;
  -webkit-touch-callout: none; /* 長押しで出るメニューを止める */
  touch-action: manipulation;
}

/* 下に重なった紙の縁(厚み)と、紙。線は幅の 1/36(72px で 2px) */
.daypad__pad::before,
.daypad__page {
  position: absolute;
  inset: 0;
  box-sizing: border-box;
  border: calc(var(--w) / 36) solid var(--ink);
  border-radius: inherit;
  background: var(--surface);
}

.daypad__pad::before { content: ""; translate: 0 calc(var(--w) * 5 / 72); }

/* --shadow はめくれている紙が落とす陰、--shade はめくれて傾いた紙の暗さ(スクリプトが動かす) */
.daypad__page { --shadow: 0; --shade: 0; display: flex; flex-direction: column; align-items: center; overflow: hidden; }

.daypad__page::before,
.daypad__page::after { content: ""; position: absolute; inset: 0; pointer-events: none; }
.daypad__page::before { background: linear-gradient(180deg, #000000, rgba(0, 0, 0, 0) 70%); opacity: var(--shadow); }
.daypad__page::after { background: #000000; opacity: var(--shade); }

.daypad__month,
.daypad__day,
.daypad__week { line-height: 1; letter-spacing: 0.06em; }

.daypad__month {
  align-self: stretch;
  display: grid;
  place-items: center;
  height: calc(var(--w) * 21 / 72);
  background: var(--band);
  color: var(--band-ink);
  font-size: calc(var(--w) / 6);
  font-weight: 700;
}

.daypad__day { margin-top: calc(var(--w) * 5 / 72); font-size: calc(var(--w) * 32 / 72); font-weight: 800; letter-spacing: -0.02em; font-variant-numeric: tabular-nums; }

.daypad__week { margin-top: calc(var(--w) * 4 / 72); color: var(--mute); font-size: calc(var(--w) * 11 / 72); font-weight: 600; }

/* めくれて落ちる紙(スクリプトが紙を写して作る)。綴じ輪より下、下の紙より上 */
.daypad__page--fly { z-index: 1; pointer-events: none; }

/* 綴じ輪(左右 2 つ) */
.daypad__ring { position: absolute; z-index: 2; top: calc(var(--w) * -6 / 72); left: calc(var(--w) * 19 / 72); width: calc(var(--w) / 12); height: calc(var(--w) * 14 / 72); border-radius: 999px; background: var(--ink); }

.daypad__ring + .daypad__ring { left: calc(var(--w) * 47 / 72); }

.daypad__status { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }

/* 副ボタン(1px の線の丸帯) */
.daypad__today {
  height: 48px;
  padding: 0 32px;
  border: 1px solid var(--ink);
  border-radius: 999px;
  background: none;
  color: inherit;
  font-family: inherit;
  font-size: 15px;
  font-weight: 600;
  letter-spacing: 0.04em;
  cursor: pointer;
}

.daypad__pad:focus-visible,
.daypad__today:focus-visible { outline: 2px solid var(--ink); outline-offset: 6px; }

JavaScript
import { gsap } from "gsap";

const LIFT = 0.24; // 手前へ 64° 起こす長さ
const FALL = 0.42; // 落ちながら 84° まで倒れて消える長さ
const BACK = 0.6; // 「今日に戻る」で紙が舞い戻る長さ
const HOLD_DELAY = 0.45; // 押し続けて早くめくり始めるまで
const REPEAT = 0.2; // 早くめくる間隔
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const monthOf = new Intl.DateTimeFormat("ja-JP", { month: "long" });
const weekOf = new Intl.DateTimeFormat("ja-JP", { weekday: "short" });
const spoken = new Intl.DateTimeFormat("ja-JP", { month: "long", day: "numeric", weekday: "long" });
const startOfToday = () => new Date(new Date().setHours(0, 0, 0, 0));

document.querySelectorAll(".daypad").forEach((box) => {
  const pad = box.querySelector(".daypad__pad");
  const page = box.querySelector(".daypad__page");
  const status = box.querySelector(".daypad__status");
  let date = startOfToday();
  const fill = (sheet, day) => {
    sheet.querySelector(".daypad__month").textContent = monthOf.format(day);
    sheet.querySelector(".daypad__day").textContent = String(day.getDate());
    sheet.querySelector(".daypad__week").textContent = weekOf.format(day);
  };
  const announce = () => (status.textContent = spoken.format(date));
  fill(page, date);
  announce();

  // 紙の写しを作り、めくって落とす動きを積む。紙の姿勢は、綴じ目(上の辺)を軸にした rotationX
  const flight = (day) => {
    const sheet = page.cloneNode(true);
    sheet.classList.add("daypad__page--fly");
    fill(sheet, day);
    const h = page.offsetHeight;
    gsap.set(sheet, { transformPerspective: h * 3, transformOrigin: "50% 0", "--shadow": 0 });
    const tl = gsap
      .timeline({ paused: true })
      .to(sheet, { rotationX: 64, "--shade": 0.2, duration: LIFT, ease: "power2.in" })
      .to(page, { "--shadow": 0.14, duration: LIFT, ease: "power2.in", overwrite: "auto" }, 0)
      .to(page, { "--shadow": 0, duration: FALL, ease: "none", overwrite: "auto" }, LIFT)
      .to(sheet, { y: h * 1.1, duration: FALL, ease: "power2.in" }, LIFT)
      .to(sheet, { rotationX: 84, rotation: -6, "--shade": 0.22, duration: FALL, ease: "none" }, LIFT)
      .to(sheet, { opacity: 0, duration: FALL * 0.55, ease: "power1.in" }, LIFT + FALL * 0.45);
    return { sheet, tl };
  };

  // 1 日進める。下の紙はすぐ次の日にし、上には今日までの紙の写しを重ねて落とす
  const flip = (speak) => {
    const { sheet, tl } = flight(date);
    date = new Date(date.getFullYear(), date.getMonth(), date.getDate() + 1);
    fill(page, date);
    if (speak) announce();
    if (reduceMotion) return;
    page.after(sheet); // 後からめくった紙は、先の紙より下に重ねる
    tl.eventCallback("onComplete", () => sheet.remove()).play();
  };

  // 今日の紙を、落ちた所から逆の道筋で重ねる。重なったら下の紙も今日にする
  const backToToday = () => {
    const today = startOfToday();
    if (+today === +date) return;
    const land = () => {
      date = today;
      fill(page, date);
      announce();
    };
    if (reduceMotion) return land();
    const { sheet, tl } = flight(today);
    pad.append(sheet);
    tl.eventCallback("onReverseComplete", () => {
      land();
      sheet.remove();
    });
    tl.progress(1).timeScale((LIFT + FALL) / BACK).reverse();
  };

  // 押したらすぐめくり、押し続けると早くめくる。マウスのクリックは pointerdown で受けたので、click は
  // キーボードと支援技術の「押す」(detail が 0)だけにする
  let hold = null;
  const repeat = () => {
    flip(false);
    hold = gsap.delayedCall(REPEAT, repeat);
  };
  pad.addEventListener("pointerdown", (e) => {
    if (e.button !== 0) return;
    flip(false);
    hold?.kill();
    hold = gsap.delayedCall(HOLD_DELAY, repeat);
  });
  const release = () => {
    if (!hold) return;
    hold.kill();
    hold = null;
    announce(); // 押し続けている間は読み上げが追いつかないので、離したときに 1 回だけ書く
  };
  ["pointerup", "pointerleave", "pointercancel"].forEach((type) => pad.addEventListener(type, release));
  pad.addEventListener("click", (e) => e.detail === 0 && flip(true));
  pad.addEventListener("contextmenu", (e) => e.preventDefault());
  box.querySelector(".daypad__today").addEventListener("click", backToToday);
});

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

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