デモ
デモを読み込めませんでした。
仕様
- 用途
- アイコン
- 動作
- 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 つで決まり、ほかの寸法はその比で決まります。
コード
<!-- 日めくりのカレンダー。押すと 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>
.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; }
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);
});