Rotating word prism
Pure CSSFour words on the four long sides of a prism that turns a quarter at a time, pausing on each. After 360° it is back where it started, so the loop has no seam.
A departure board: each character is four half-height leaves, and a change drops the old top half and lands the new bottom half. JS only swaps the letters and restarts the CSS animation.
Your edited version
top/bottom show the current split, and fall/land are the ones that animate between the old and new character.content: attr(data-c), so JS never touches a text node — it only ever writes a data-c attribute..is-flip class, reads a layout property to force the browser to flush styles, then re-adds the class.animation-fill-mode: both matters: outside its delay the falling leaf sits parked edge-on at rotateX(-90deg), and the landing leaf holds its open pose until its own delay ends.--i staggers each cell by 45ms so the flips ripple across the row instead of firing together, and it carries on into the second row, so the board cascades.--u, tied to the canvas: a cell is 21 × 34 of them, so the board is the same share of a gallery card, the editor and a recording canvas.two hinged half-leaves per cellcontent: attr(data-c)restart animation by re-adding a classanimation-fill-mode: both<div class="board" role="img" aria-label="Split-flap departure board cycling through city names">
<div class="board-head"><span>Departures</span><b>Gate 3D</b></div>
<div class="board-row">
<span class="cell" style="--i:0"><i class="cell-top"></i><i class="cell-bottom"></i><i class="cell-fall"></i><i class="cell-land"></i></span>
<span class="cell" style="--i:1"><i class="cell-top"></i><i class="cell-bottom"></i><i class="cell-fall"></i><i class="cell-land"></i></span>
<span class="cell" style="--i:2"><i class="cell-top"></i><i class="cell-bottom"></i><i class="cell-fall"></i><i class="cell-land"></i></span>
<span class="cell" style="--i:3"><i class="cell-top"></i><i class="cell-bottom"></i><i class="cell-fall"></i><i class="cell-land"></i></span>
<span class="cell" style="--i:4"><i class="cell-top"></i><i class="cell-bottom"></i><i class="cell-fall"></i><i class="cell-land"></i></span>
<span class="cell" style="--i:5"><i class="cell-top"></i><i class="cell-bottom"></i><i class="cell-fall"></i><i class="cell-land"></i></span>
<span class="cell" style="--i:6"><i class="cell-top"></i><i class="cell-bottom"></i><i class="cell-fall"></i><i class="cell-land"></i></span>
<span class="cell" style="--i:7"><i class="cell-top"></i><i class="cell-bottom"></i><i class="cell-fall"></i><i class="cell-land"></i></span>
</div>
<div class="board-row">
<span class="cell" style="--i:8"><i class="cell-top"></i><i class="cell-bottom"></i><i class="cell-fall"></i><i class="cell-land"></i></span>
<span class="cell" style="--i:9"><i class="cell-top"></i><i class="cell-bottom"></i><i class="cell-fall"></i><i class="cell-land"></i></span>
<span class="cell" style="--i:10"><i class="cell-top"></i><i class="cell-bottom"></i><i class="cell-fall"></i><i class="cell-land"></i></span>
<span class="cell" style="--i:11"><i class="cell-top"></i><i class="cell-bottom"></i><i class="cell-fall"></i><i class="cell-land"></i></span>
<span class="cell" style="--i:12"><i class="cell-top"></i><i class="cell-bottom"></i><i class="cell-fall"></i><i class="cell-land"></i></span>
<span class="cell" style="--i:13"><i class="cell-top"></i><i class="cell-bottom"></i><i class="cell-fall"></i><i class="cell-land"></i></span>
<span class="cell" style="--i:14"><i class="cell-top"></i><i class="cell-bottom"></i><i class="cell-fall"></i><i class="cell-land"></i></span>
<span class="cell" style="--i:15"><i class="cell-top"></i><i class="cell-bottom"></i><i class="cell-fall"></i><i class="cell-land"></i></span>
</div>
</div>
/* One row of eight cells is a shape the band cannot hold: it draws three times wider than it
is tall, so at the 92vmin width limit the board stands 30vmin high, under the 40vmin floor.
Two destinations is what a departure board shows anyway. */
.board {
/* one base unit: every length below is a multiple of it, so the board is the same share of a
card, the editor, a full screen and a recording canvas */
--u: 0.40vmin;
display: grid;
gap: calc(8 * var(--u));
padding: calc(12 * var(--u)) calc(12 * var(--u)) calc(10 * var(--u));
border: calc(1 * var(--u)) solid #3a4070;
border-radius: calc(12 * var(--u));
background: linear-gradient(160deg, #211c3c, #0b0d18 70%);
box-shadow: 0 calc(18 * var(--u)) calc(30 * var(--u)) calc(-18 * var(--u)) rgb(0 0 0 / 0.8);
}
.board-head {
display: flex;
align-items: center;
justify-content: space-between;
color: #949bc0;
font-size: calc(9 * var(--u));
font-weight: 700;
letter-spacing: 0.16em;
text-transform: uppercase;
}
.board-head b {
color: #ffb547;
font-weight: 800;
}
.board-row {
display: flex;
gap: calc(3 * var(--u));
}
.cell {
position: relative;
width: calc(21 * var(--u));
height: calc(34 * var(--u));
perspective: calc(140 * var(--u)); /* its own close camera: a 17-unit leaf needs a strong one to read as falling */
}
/* the slit between the two halves */
.cell::after {
content: '';
position: absolute;
top: calc(50% - 0.5 * var(--u));
right: 0;
left: 0;
height: calc(1 * var(--u));
background: #05060c;
}
.cell i {
position: absolute;
right: 0;
left: 0;
height: 50%;
overflow: hidden; /* a leaf, so clipping here flattens nothing */
backface-visibility: hidden;
}
/* the whole character, twice the leaf's height; the leaf shows one half of it */
.cell i::before {
content: attr(data-c);
position: absolute;
right: 0;
left: 0;
height: 200%;
color: #f4f1e6;
font: 800 calc(22 * var(--u))/calc(34 * var(--u)) 'JetBrains Mono', ui-monospace, monospace;
text-align: center;
}
.cell-top,
.cell-fall {
top: 0;
border-radius: calc(4 * var(--u)) calc(4 * var(--u)) 0 0;
background: #1a1e36;
}
.cell-top::before,
.cell-fall::before {
top: 0;
}
/* bottom halves: slightly lighter, as if lit from above */
.cell-bottom,
.cell-land {
bottom: 0;
border-radius: 0 0 calc(4 * var(--u)) calc(4 * var(--u));
background: #20254a;
}
.cell-bottom::before,
.cell-land::before {
bottom: 0;
}
.cell-fall {
transform: rotateX(-90deg); /* parked edge-on = invisible */
transform-origin: 50% 100%;
}
.cell-land {
transform-origin: 50% 0;
}
/* "both": during its delay the landing leaf waits edge-on at 90deg, afterwards it stays down
and simply covers the static bottom half */
.cell.is-flip .cell-fall {
animation: flap-fall 0.17s ease-in calc(var(--i) * 45ms) both;
}
.cell.is-flip .cell-land {
animation: flap-land 0.17s ease-out calc(var(--i) * 45ms + 0.17s) both;
}
@keyframes flap-fall {
from { transform: rotateX(0deg); }
to { transform: rotateX(-90deg); }
}
@keyframes flap-land {
from { transform: rotateX(90deg); }
to { transform: rotateX(0deg); }
}
var WORDS = ['NEW YORK', 'HELSINKI', 'LISBON', 'BANGKOK', 'SAN JOSE', 'CSS 3D'];
// one list of cells per row; the two rows show two destinations, one behind the other
var rows = Array.prototype.map.call(document.querySelectorAll('.board-row'), function (row) {
return Array.prototype.map.call(row.querySelectorAll('.cell'), function (cell) {
return { cell: cell, leaves: cell.querySelectorAll('i'), char: ' ' }; // top, bottom, fall, land
});
});
function show(cells, word, animate) {
var text = word + ' '; // pad so every cell always has a character
cells.forEach(function (c, i) {
var next = text[i];
if (next === c.char && animate) return;
var top = c.leaves[0];
var bottom = c.leaves[1];
var fall = c.leaves[2];
var land = c.leaves[3];
top.dataset.c = next; // revealed as the old top half falls
land.dataset.c = next; // lands on top of the old bottom half
fall.dataset.c = c.char;
bottom.dataset.c = c.char;
c.char = next;
if (!animate) return;
// restart the CSS animation: drop the class, force a style flush, add it again
c.cell.classList.remove('is-flip');
void c.cell.offsetWidth;
c.cell.classList.add('is-flip');
});
}
var index = 0;
rows.forEach(function (cells, r) {
show(cells, WORDS[r % WORDS.length], false);
});
// The site pauses a model by marking the frame's <html data-paused>; a visitor may ask for less
// motion. Either way the board holds its word.
var calm = matchMedia('(prefers-reduced-motion: reduce)');
setInterval(function () {
if (document.documentElement.hasAttribute('data-paused') || calm.matches) return;
index = (index + 1) % WORDS.length;
rows.forEach(function (cells, r) {
show(cells, WORDS[(index + r) % WORDS.length], true);
});
}, 2600);