デモ
デモを読み込めませんでした。
仕様
- 用途
- ヒーロー
- 動作
- DOM
- 種類
- レシピ
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
1 文字を上下 2 枚の半分の札で表します。静止した上下の札に加えて、めくるときだけ現れる 2 枚のフラップを重ね、rotateX で 90° まで上のフラップが倒れ、残り 90° で下から次の文字のフラップが現れます。空港の発着案内のような、反転フラップ(パタパタ)の盤面です。
1 枚めくるのに 0.099 秒で、変わる文字は目的の文字まで 4〜8 枚をランダムに送ってから止まります。列ごとに 0.066 秒ずつ遅らせるので、左から波のように変わります。札の並びはひらがな・カタカナの五十音の順で、そこに無い漢字は並びの最後に足してあります。
フラップのしまい忘れがあると、次にめくったときに前の傾きが残ります。めくり終えたら visibility を hidden に戻し、傾きも外します。文言は盤面の aria-label に持たせ、札そのものは読み上げの対象から外します。
送る枚数の範囲を広げるほど、めくる時間にばらつきが出ます。5 秒より長く動き続けるので、一時停止のボタンを添えています。旅行・交通・イベントのカウントダウンなど、見出しの文字が入れ替わる演出に向いています。
コード
<section class="flap-board" aria-labelledby="flap-board-title">
<h2 class="flap-board__title" id="flap-board-title">週末はリスボンへ</h2>
<!-- 盤面が順に映す文言。読み上げには盤面の role="img" + aria-label のほうを届けるので、ここは隠す -->
<ul class="flap-board__messages" hidden>
<li>週末はリスボンへ</li>
<li>朝日を見にバリ島へ</li>
<li>紅葉を見に京都へ</li>
</ul>
<!-- 札は script が組み立てる -->
<div class="flap-board__panel">
<div class="flap-board__row"></div>
</div>
<!-- 5 秒より長く動き続けるものには、止める手段を置く。動かすときだけ JS が出す -->
<button class="flap-board__toggle" type="button" aria-pressed="false" hidden>一時停止</button>
</section>
.flap-board {
--bg: #111111;
--panel: #262626;
--face: #f7f2e7;
--face-bottom: #ece6d8;
--glyph: #15161a;
--ink: #eeece6;
--cell: clamp(40px, 9vw, 72px); /* 札 1 枚の高さ。ほかの寸法はこの比で決まる */
display: grid;
justify-items: center;
gap: 32px;
padding: 96px 6vw;
background: var(--bg);
color: var(--ink);
text-align: center;
}
.flap-board__title { margin: 0; max-width: 26em; font-size: clamp(20px, 3vw, 28px); font-weight: 700; line-height: 1.4; letter-spacing: -0.01em; }
.flap-board__panel { padding: calc(var(--cell) * 0.15) calc(var(--cell) * 0.2) calc(var(--cell) * 0.2); border-radius: 8px; background: var(--panel); }
.flap-board__row { display: flex; gap: calc(var(--cell) * 0.067); }
/* 1 文字ぶんの札。perspective はここに置き、中の層(half)がその奥行きで回る */
.flap-board__cell {
position: relative;
flex: none;
width: calc(var(--cell) * 0.74);
height: var(--cell);
font-size: calc(var(--cell) * 0.58);
font-weight: 800;
border-radius: 4px;
overflow: hidden;
perspective: calc(var(--cell) * 3.6);
}
/* 上下の窓。高さは札の半分。glyph はここから溢れる残り半分をのぞかせて字の上下をつなげる */
.flap-board__half {
position: absolute;
inset: 0 0 auto;
height: 50%;
overflow: hidden;
background: var(--face);
border-radius: 4px 4px 0 0;
transform-origin: 50% 100%;
}
.flap-board__half--bottom { top: 50%; background: var(--face-bottom); border-radius: 0 0 4px 4px; transform-origin: 50% 0%; }
.flap-board__half--flap { z-index: 2; visibility: hidden; } /* めくる層。rotateX は JS が入れる */
.flap-board__half span { position: absolute; inset: 0 0 auto; height: 200%; display: block; line-height: var(--cell); text-align: center; color: var(--glyph); }
.flap-board__half--bottom span { top: -100%; }
.flap-board__toggle {
height: 40px;
padding: 0 20px;
border: 0;
border-radius: 999px;
box-shadow: inset 0 0 0 1px var(--ink);
background: transparent;
color: var(--ink);
font: inherit;
font-size: 14px;
font-weight: 600;
letter-spacing: 0.04em;
cursor: pointer;
}
.flap-board__toggle[aria-pressed="true"] { background: var(--ink); color: var(--bg); }
.flap-board__toggle:focus-visible { outline: 2px solid var(--ink); outline-offset: 4px; }
import { gsap } from "gsap";
// 札の並び順(先頭は空白)。この順にしかめくれない。ここに無い字(漢字など)は、盤面ごとに最後へ足す
const KANA = " あいうえおかがきぎくぐけげこごさざしじすずせぜそぞただちぢつづてでとどなにぬねのはばぱひびぴふぶぷへべぺほぼぽまみむめもやゆよらりるれろわをんアイウエオカガキギクグケゲコゴサザシジスズセゼソゾタダチヂツヅテデトドナニヌネノハバパヒビピフブプヘベペホボポマミムメモヤユヨラリルレロワヲンー";
const FLAP = 0.099; // 1 枚めくる時間(秒)
const EACH = 0.066; // 隣の列とのずれ
const SWITCH = 3.432; // 次の文言に変わるまでの間隔(秒)
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
// 半分の札を 1 枚作る(bottom: 下半分か / flap: めくるときだけ出す層か)。寸法は CSS が --cell から決める
function half(parent, bottom, flap) {
const node = document.createElement("div");
node.className = `flap-board__half${bottom ? " flap-board__half--bottom" : ""}${flap ? " flap-board__half--flap" : ""}`;
const glyph = document.createElement("span");
node.append(glyph);
parent.append(node);
return { node, glyph };
}
// 1 文字ぶんの札。静止した上下の半分と、めくるときだけ出る上下のフラップ
function makeCell() {
const el = document.createElement("div");
el.className = "flap-board__cell";
const f = (bottom, flap) => half(el, bottom, flap);
return { el, char: "", top: f(false, false), bottom: f(true, false), topFlap: f(false, true), bottomFlap: f(true, true) };
}
const paint = (face, ch, transform = "") => {
face.glyph.textContent = ch;
face.node.style.transform = transform;
face.node.style.visibility = "visible";
};
const hide = (face) => { face.node.style.visibility = "hidden"; face.node.style.transform = ""; };
function settle(cell, ch) {
paint(cell.top, ch);
paint(cell.bottom, ch);
hide(cell.topFlap);
hide(cell.bottomFlap);
cell.char = ch;
}
// from → to を 1 枚めくる途中の絵(p: 0〜1)。奥行き(perspective)は CSS 側(cell)が持つ
function flip(cell, from, to, p) {
const a = 180 * p;
if (a < 90) {
paint(cell.topFlap, from, `rotateX(${-a}deg)`);
hide(cell.bottomFlap);
} else {
hide(cell.topFlap);
paint(cell.bottomFlap, to, `rotateX(${180 - a}deg)`);
}
}
// from から to まで、drum の順に送る文字の並び(先頭は from)
function sequence(drum, from, to, flips) {
const end = drum.indexOf(to);
const chars = [from];
for (let n = flips - 1; n >= 0; n--) chars.push(drum[(end - n + drum.length) % drum.length]);
return chars;
}
document.querySelectorAll(".flap-board").forEach((section) => {
const row = section.querySelector(".flap-board__panel .flap-board__row");
// 盤面が映す文言は HTML(隠しリスト)から読む。字数がそろうよう、いちばん長い語に合わせて空白で埋める
const words = [...section.querySelectorAll(".flap-board__messages li")].map((li) => li.textContent.trim().toUpperCase());
const cols = Math.max(...words.map((w) => w.length));
const messages = words.map((w) => w.padEnd(cols, " "));
const drum = KANA + [...new Set(words.join(""))].filter((ch) => !KANA.includes(ch)).join("");
const cells = [...messages[0]].map((ch) => {
const cell = makeCell();
settle(cell, ch);
row.append(cell.el);
return cell;
});
row.setAttribute("role", "img");
row.setAttribute("aria-label", messages[0].trim());
if (reduceMotion) return; // 動きを減らす設定では最初の文言のまま置く
function show(target) {
const tl = gsap.timeline();
cells.forEach((cell, col) => {
const ch = target[col];
if (ch === cell.char) return;
const chars = sequence(drum, cell.char, ch, gsap.utils.random(4, 8, 1));
cell.char = ch;
const state = { k: 0 };
const draw = () => {
const j = Math.floor(state.k);
j >= chars.length - 1 ? settle(cell, chars[j]) : flip(cell, chars[j], chars[j + 1], state.k - j);
};
tl.to(state, { k: chars.length - 1, duration: (chars.length - 1) * FLAP, ease: "none", onUpdate: draw }, col * EACH);
});
row.setAttribute("aria-label", target.trim());
return tl;
}
const loop = gsap.timeline({ repeat: -1 }).set({}, {}, messages.length * SWITCH); // 1 周の長さを文言の数 × 間隔にそろえる
messages.forEach((_, i) => loop.call(show, [messages[(i + 1) % messages.length]], i * SWITCH + 0.528));
const toggle = section.querySelector(".flap-board__toggle");
toggle.hidden = false;
toggle.addEventListener("click", () => {
const paused = loop.paused();
loop.paused(!paused);
toggle.setAttribute("aria-pressed", String(!paused));
});
});