Split-flap board
CSS + JSA 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.
Every letter does the same flip; the stagger turns it into a wave.
Your edited version
<span> with display: inline-block — transforms are ignored on plain inline boxes.backface-visibility: hidden is the front, and a ::after with content: attr(data-c), turned rotateY(180deg) and hidden from behind too, is the back. Halfway through the flip you read the back, the right way round, never a mirrored letter.animation-delay: calc(var(--i) * 0.12s) offsets each letter, and the offsets read as a travelling wave.aria-label and hide the spans from screen readers, or it is read letter by letter.--i carries on from WAVE into 3D, so the wave rolls across the gap between the words.wide in its gallery entry.--u, the lift toward the camera included, so the word is the same share of a gallery card, the editor and a recording canvas.inline-block lettersanimation-delay from --irotateY flip<div class="scene">
<h1 class="wave" aria-label="WAVE 3D">
<span class="line">
<span style="--i:0" data-c="W" aria-hidden="true">W</span>
<span style="--i:1" data-c="A" aria-hidden="true">A</span>
<span style="--i:2" data-c="V" aria-hidden="true">V</span>
<span style="--i:3" data-c="E" aria-hidden="true">E</span>
</span>
<span class="line">
<span style="--i:4" data-c="3" aria-hidden="true">3</span>
<span style="--i:5" data-c="D" aria-hidden="true">D</span>
</span>
</h1>
</div>
.scene {
/* one base unit: every length below is a multiple of it, so the word is the same share of a
card, the editor, a full screen and a recording canvas */
--u: 0.26vmin;
perspective: calc(600 * var(--u));
}
/* One line: WAVE, a word's gap, 3D. Seven letters in a row are about four times wider than they
are tall, so the word is sized by its width (it is marked 'wide'): the unit puts it near the
width the gallery's other wide models have */
.wave {
display: flex;
align-items: center;
gap: calc(22 * var(--u));
margin: 0;
font: 900 calc(64 * var(--u))/1 Inter, system-ui;
transform-style: preserve-3d;
}
.wave .line {
display: flex;
gap: calc(2 * var(--u));
transform-style: preserve-3d;
}
/* each letter is a card with the letter on both sides: the span's own text is the front and
hides once it turns away, and ::after is the same letter turned round to face the back, so a
letter mid-flip never shows mirrored */
.wave .line span {
position: relative;
display: inline-block;
/* one lightness for every hue, 3:1 or more on both stages (large text): hsl at 70% was 2.3:1 on
the light one */
color: oklch(62% 0.22 calc(290 + var(--i) * 18));
transform-style: preserve-3d;
backface-visibility: hidden;
animation: wave 2.8s ease-in-out infinite;
animation-delay: calc(var(--i) * 0.12s);
}
.wave .line span::after {
content: attr(data-c);
position: absolute;
inset: 0;
backface-visibility: hidden;
transform: rotateY(180deg);
}
@keyframes wave {
0% { transform: translateZ(0) rotateY(0deg); }
22% { transform: translateZ(calc(60 * var(--u))) rotateY(180deg); }
45%, 100% { transform: translateZ(0) rotateY(360deg); }
}