デモ
デモを読み込めませんでした。
仕様
- 用途
- ヒーロー
- 動作
- DOM
- 種類
- レシピ
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
左の列から順に、文字がパタパタとめくれていき、何枚か送ったあとで新しい文字に止まります。空港の発着案内でおなじみの、反転フラップの表示です。
文字はそれぞれ、上半分と下半分の静止したパネルでできていて、めくるときだけその上にフラップを重ねます。rotateX でまず前の文字の上半分が90°まで倒れ、残りの半分は次の文字の下半分が引き継いで倒れきります。上下で半分ずつ受け持つので、ひと続きのフラップが折れ目を軸にめくれていくように見えます。めくり終えたフラップは visibility を hidden に戻し、傾きも外しておきます。隠し忘れると、次のめくりが前の傾きの残ったところから始まってしまいます。
ひとめくりは0.099秒。変わる文字は、目当ての文字へいきなり切り替えず、4〜8枚のあいだでランダムに決めた枚数を送ってから止まるので、列ごとに止まる時刻がばらけます。列の開始も0.066秒ずつ遅らせて、変化が左から波のように伝わるようにしました。めくる文字の並びはひらがなとカタカナの五十音順で、そこに無い漢字は並びの最後に足してあります。
読み上げには、文字を並べた要素全体の 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));
});
});