Magazine page turn
A two-page magazine spread whose leaves turn one by one, with a shadow that follows the curl.
An airport departures board that rattles through letters to a new word at each step of the scroll.
Scroll progress picks a word from a list. When the word changes, every cell cycles through a few random characters before landing on its letter, with a small delay per cell. Each change restarts a short rotateX animation on the cell, which reads as a flap dropping.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Split-flap board</title>
<style>
*, *::before, *::after { box-sizing: border-box; }
html, body { margin: 0; }
body { font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; -webkit-font-smoothing: antialiased; }
body { background: #101114; color: #f7f3e8; }
.track { height: 500vh; }
.stage { position: sticky; top: 0; height: 100vh; display: grid; place-content: center; justify-items: center; gap: 24px; padding: 0 4vw; }
.label { margin: 0; font: 600 .85rem ui-monospace, monospace; letter-spacing: .3em; color: #8d8a80; text-transform: uppercase; }
.board { display: flex; gap: 5px; perspective: 400px; }
.cell {
position: relative; width: clamp(28px, 8vw, 78px); aspect-ratio: .72; border-radius: 6px;
background: linear-gradient(#24262c 0 49.5%, #0d0e11 49.5% 50.5%, #1d1f24 50.5%);
display: grid; place-items: center; color: #ffd84d;
font: 700 clamp(1.4rem, 5.6vw, 3.6rem) ui-monospace, Menlo, monospace;
box-shadow: 0 6px 14px rgba(0,0,0,.5);
}
.cell.flip { animation: flap .14s ease-in; transform-origin: 50% 50%; }
@keyframes flap { from { transform: rotateX(-70deg); filter: brightness(1.8); } to { transform: none; } }
</style>
</head>
<body>
<section class="track">
<div class="stage">
<p class="label">Now showing</p>
<div class="board" id="board"></div>
<p class="label" id="step">1 / 6</p>
</div>
</section>
<script>
const words = ['SCROLLBOX', 'DEPARTING', 'GATE 12', 'BOARDING', 'ON TIME', 'ARRIVED'];
const W = 9, CH = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
const board = document.getElementById('board');
const step = document.getElementById('step');
const cells = [];
for (let i = 0; i < W; i++) { const c = document.createElement('div'); c.className = 'cell'; board.appendChild(c); cells.push({ el: c, timer: 0 }); }
function setChar(cell, ch) {
cell.el.textContent = ch;
cell.el.classList.remove('flip'); void cell.el.offsetWidth; cell.el.classList.add('flip');
}
function show(word) {
const target = word.padEnd(W, ' ');
cells.forEach((cell, i) => {
clearInterval(cell.timer);
let spins = 3 + Math.floor(Math.random() * 4);
setTimeout(() => {
cell.timer = setInterval(() => {
if (spins-- <= 0) { clearInterval(cell.timer); setChar(cell, target[i]); return; }
setChar(cell, CH[Math.floor(Math.random() * CH.length)]);
}, 70);
}, i * 45);
});
}
const track = document.querySelector('.track');
let current = -1;
function update() {
const r = track.getBoundingClientRect();
const p = Math.min(0.9999, Math.max(0, -r.top / (r.height - innerHeight)));
const idx = Math.floor(p * words.length);
if (idx !== current) { current = idx; show(words[idx]); step.textContent = (idx + 1) + ' / ' + words.length; }
}
addEventListener('scroll', update, { passive: true });
update();
</script>
</body>
</html>