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.
WAVING RIBBON on two lines of violet slats, one letter each. Every slat runs the same twist, offset by a step of the loop, so the row reads as a ribbon turning in a wind, and the twist stops short of edge-on so every letter stays readable.
Your edited version
<span> with display: inline-block (a plain inline box ignores transforms) and a coloured panel of its own, so the letter always has a solid surface behind it whatever the stage.rotateX and rising and falling a little. animation-delay: calc(var(--i) * -0.375s) puts each slat a step further along the loop: eight steps make one wavelength, so neighbouring slats lean a little more than each other and the row reads as one ribbon twisting in a wind.backface-visibility: hidden, is the violet front, and a ::after with content: attr(data-c), turned rotateX(180deg) and hidden from behind too, is the pink back. At this twist the back stays out of sight; it is there so that a slat turned further (edit the 60° and see) shows its letter the right way round on either side, never mirrored.--i carries on from WAVING into RIBBON, so the same wave rolls through both. Every length is a multiple of one base unit, --u, so the banner is the same share of a gallery card, the editor and a recording canvas.one keyframe, animation-delay: calc(var(--i) * -0.375s)rotateX about the ribbon’s axistwo-sided slat: ::after rotateX(180deg)backface-visibility: hidden<div class="scene">
<div class="ribbon" role="img" aria-label="WAVING RIBBON">
<span class="line">
<span class="slat" style="--i:0" data-c="W" aria-hidden="true">W</span>
<span class="slat" style="--i:1" data-c="A" aria-hidden="true">A</span>
<span class="slat" style="--i:2" data-c="V" aria-hidden="true">V</span>
<span class="slat" style="--i:3" data-c="I" aria-hidden="true">I</span>
<span class="slat" style="--i:4" data-c="N" aria-hidden="true">N</span>
<span class="slat" style="--i:5" data-c="G" aria-hidden="true">G</span>
</span>
<span class="line">
<span class="slat" style="--i:6" data-c="R" aria-hidden="true">R</span>
<span class="slat" style="--i:7" data-c="I" aria-hidden="true">I</span>
<span class="slat" style="--i:8" data-c="B" aria-hidden="true">B</span>
<span class="slat" style="--i:9" data-c="B" aria-hidden="true">B</span>
<span class="slat" style="--i:10" data-c="O" aria-hidden="true">O</span>
<span class="slat" style="--i:11" data-c="N" aria-hidden="true">N</span>
</span>
</div>
</div>
.scene {
/* one base unit: every length below is a multiple of it, so the banner is the same share of a
card, the editor, a full screen and a recording canvas */
--u: 0.42vmin;
perspective: calc(800 * var(--u));
}
/* two lines of six slats: 192 units wide, 134 tall before any slat tilts. Six letters on one line
would be over three times wider than tall, well under the band's 40vmin floor at any width the
band allows; a banner with two lines is a layout it would have anyway */
.ribbon {
display: grid;
gap: calc(6 * var(--u));
transform-style: preserve-3d;
}
.ribbon .line {
display: flex;
transform-style: preserve-3d;
}
/* a slat is a panel with the letter on it: 32 units wide, 64 tall. Slats butt together with no
gap, so a line reads as one ribbon rather than a row of cards */
.ribbon .slat {
position: relative;
display: inline-block;
box-sizing: border-box;
width: calc(32 * var(--u));
height: calc(64 * var(--u));
font: 700 calc(32 * var(--u)) / calc(64 * var(--u)) Inter, system-ui, sans-serif;
text-align: center;
color: #fff;
background: #6a45f5;
transform-style: preserve-3d;
backface-visibility: hidden;
animation: twist 3s ease-in-out infinite;
/* a step of one eighth of the loop per slat: eight slats to a wavelength. Negative, so the
first frame is already a wave */
animation-delay: calc(var(--i) * -0.375s);
}
/* the back of the ribbon: the same letter, turned to face away, in the ribbon's other colour, so
a slat that tilts far enough to show its back still shows a readable letter. White on both
sides: the contrast check reads a turned-away face against the pixels in front of it, and white
is 5:1 or more on either panel */
.ribbon .slat::after {
content: attr(data-c);
position: absolute;
inset: 0;
color: #fff;
background: #d1206f;
backface-visibility: hidden;
transform: rotateX(180deg);
}
/* a twist about the ribbon's long axis, and a small rise a quarter-wave behind it, so the slats
lean into the crest the way a ribbon does. Symmetrical easing, and the end is the start: no seam */
@keyframes twist {
0% { transform: translateY(0) rotateX(60deg); }
25% { transform: translateY(calc(5 * var(--u))) rotateX(0deg); }
50% { transform: translateY(0) rotateX(-60deg); }
75% { transform: translateY(calc(-5 * var(--u))) rotateX(0deg); }
100% { transform: translateY(0) rotateX(60deg); }
}