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 sits on its own cube. The cubes roll forward a quarter turn one after another, so CUBE becomes ROLL and back; after four turns each cube is where it started, so the loop has no seam.
Your edited version
rotateX(n × -90deg) translateZ(s / 2): front, bottom, back and top carry the letters of CUBE, ROLL, CUBE, ROLL.animation-delay: calc(var(--i) * 0.13s) starts each cube a moment after the one before it, and the turn runs along the word like a wave.backface-visibility: hidden keeps letters from showing mirrored through the cube, and a square plate behind each rounded face hides the gaps at the corners.face n: rotateX(n × -90deg) translateZ(s / 2)hold + turn keyframes to 360deganimation-delay from --ibackface-visibility: hidden<div class="scene">
<div class="word" role="img" aria-label="CUBE, rolling over to ROLL">
<span class="cube" style="--i:0" aria-hidden="true"><i>C</i><i>R</i><i>C</i><i>R</i><b></b><b></b></span>
<span class="cube" style="--i:1" aria-hidden="true"><i>U</i><i>O</i><i>U</i><i>O</i><b></b><b></b></span>
<span class="cube" style="--i:2" aria-hidden="true"><i>B</i><i>L</i><i>B</i><i>L</i><b></b><b></b></span>
<span class="cube" style="--i:3" aria-hidden="true"><i>E</i><i>L</i><i>E</i><i>L</i><b></b><b></b></span>
</div>
</div>
.scene {
perspective: 800px;
}
.word {
display: flex;
gap: 9px;
transform-style: preserve-3d;
transform: rotateX(-16deg) rotateY(-18deg); /* a fixed view from above and to the right */
}
.cube {
--s: 44px;
position: relative;
width: var(--s);
height: var(--s);
transform-style: preserve-3d;
/* --i staggers the cubes so the turn runs along the word */
animation: roll 8s calc(var(--i) * 0.13s) infinite both;
}
.cube i,
.cube b {
position: absolute;
inset: 0;
border-radius: 8px;
transform-style: preserve-3d;
}
/* letter faces: first word violet, second word pink */
.cube i {
--c: #8b6cff;
display: grid;
place-items: center;
border: 1px solid color-mix(in srgb, var(--c), #fff 35%);
background:
linear-gradient(150deg, rgb(255 255 255 / 0.3), transparent 45%),
linear-gradient(color-mix(in srgb, var(--c), #fff 8%), color-mix(in srgb, var(--c), #000 22%));
color: #fff;
font: 900 26px/1 system-ui, sans-serif;
font-style: normal;
text-shadow: 0 2px 0 color-mix(in srgb, var(--c), #000 45%);
backface-visibility: hidden; /* never a mirrored letter */
}
.cube i:nth-child(even) {
--c: #ff4d9d;
}
/* face n: n quarter turns back, then out by half a side */
.cube i:nth-child(1) { transform: rotateX(0deg) translateZ(calc(var(--s) / 2)); }
.cube i:nth-child(2) { transform: rotateX(-90deg) translateZ(calc(var(--s) / 2)); }
.cube i:nth-child(3) { transform: rotateX(-180deg) translateZ(calc(var(--s) / 2)); }
.cube i:nth-child(4) { transform: rotateX(-270deg) translateZ(calc(var(--s) / 2)); }
/* the two ends */
.cube b {
border: 1px solid color-mix(in srgb, #8b6cff, #fff 10%);
background: linear-gradient(color-mix(in srgb, #8b6cff, #000 30%), color-mix(in srgb, #8b6cff, #000 50%));
}
.cube b:nth-of-type(1) { transform: rotateY(-90deg) translateZ(calc(var(--s) / 2)); }
.cube b:nth-of-type(2) { transform: rotateY(90deg) translateZ(calc(var(--s) / 2)); }
/* rounded faces leave holes at the corners: a square plate behind each one plugs them */
.cube i::before,
.cube b::before {
content: '';
position: absolute;
inset: 0; /* full size: a smaller plate leaves a channel along each edge you can see into */
background: color-mix(in srgb, #8b6cff, #000 50%);
transform: translateZ(-5px);
}
.cube i:nth-child(even)::before {
background: color-mix(in srgb, #ff4d9d, #000 50%);
}
/* hold, quarter turn, hold... 360deg = 0deg, so the loop is seamless */
@keyframes roll {
0%, 17% {
transform: rotateX(0deg);
animation-timing-function: cubic-bezier(0.6, -0.3, 0.3, 1.3);
}
25%, 42% {
transform: rotateX(90deg);
animation-timing-function: cubic-bezier(0.6, -0.3, 0.3, 1.3);
}
50%, 67% {
transform: rotateX(180deg);
animation-timing-function: cubic-bezier(0.6, -0.3, 0.3, 1.3);
}
75%, 92% {
transform: rotateX(270deg);
animation-timing-function: cubic-bezier(0.6, -0.3, 0.3, 1.3);
}
100% {
transform: rotateX(360deg);
}
}
@use '../mixins' as *;
$s: 44px;
// One cube per letter. Around each cube's X axis: front, bottom, back, top carry the letters of
// word A, word B, word A, word B, so every quarter turn forward swaps the word.
.d-cubeletters {
display: flex;
gap: 9px;
transform-style: preserve-3d;
// a fixed view: from a little above and to the right
transform: rotateX(-16deg) rotateY(-18deg);
&__cube {
position: relative;
width: $s;
height: $s;
transform-style: preserve-3d;
// --i staggers the cubes, so the turn runs along the word like a wave
animation: d-cubeletters-roll 8s calc(var(--i) * 0.13s) infinite both;
i,
b {
position: absolute;
inset: 0;
border-radius: 8px;
}
// word A faces violet, word B faces pink
i {
--c: var(--accent);
display: grid;
place-items: center;
border: 1px solid color-mix(in srgb, var(--c), #fff 35%);
background:
linear-gradient(150deg, rgb(255 255 255 / 0.3), transparent 45%),
linear-gradient(color-mix(in srgb, var(--c), #fff 8%), color-mix(in srgb, var(--c), #000 22%));
color: #fff;
font: 900 26px/1 var(--font);
font-style: normal;
text-shadow: 0 2px 0 color-mix(in srgb, var(--c), #000 45%);
backface-visibility: hidden; // a letter must never show mirrored through the cube
@include solid-core(5px, color-mix(in srgb, var(--c), #000 50%));
&:nth-child(even) {
--c: var(--hot);
}
}
// Face n is turned n quarter turns BACK around X, then pushed out by half a side: turning
// the cube n quarter turns forward brings it to the front, upright.
@for $n from 0 through 3 {
i:nth-child(#{$n + 1}) {
transform: rotateX($n * -90deg) translateZ($s * 0.5);
}
}
// the two ends, plain and darker
b {
border: 1px solid color-mix(in srgb, var(--accent), #fff 10%);
background: linear-gradient(color-mix(in srgb, var(--accent), #000 30%), color-mix(in srgb, var(--accent), #000 50%));
@include solid-core(5px, color-mix(in srgb, var(--accent), #000 50%));
}
b:nth-of-type(1) {
transform: rotateY(-90deg) translateZ($s * 0.5);
}
b:nth-of-type(2) {
transform: rotateY(90deg) translateZ($s * 0.5);
}
}
}
// Hold, quarter turn, hold... four turns make 360deg, which looks exactly like 0deg: no seam.
// The easing is set per keyframe and only matters on the turns: a small wind-up and overshoot.
@keyframes d-cubeletters-roll {
0%,
17% {
transform: rotateX(0deg);
animation-timing-function: cubic-bezier(0.6, -0.3, 0.3, 1.3);
}
25%,
42% {
transform: rotateX(90deg);
animation-timing-function: cubic-bezier(0.6, -0.3, 0.3, 1.3);
}
50%,
67% {
transform: rotateX(180deg);
animation-timing-function: cubic-bezier(0.6, -0.3, 0.3, 1.3);
}
75%,
92% {
transform: rotateX(270deg);
animation-timing-function: cubic-bezier(0.6, -0.3, 0.3, 1.3);
}
100% {
transform: rotateX(360deg);
}
}