Pure CSS Text effects #text #loop #shape

Rolling letter cubes in pure CSS

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.

  • 114 lines of CSS
  • 8 lines of HTML
  • No JavaScript
  • No dependencies
  • Snippet under MIT
GitHub

How it works

  1. Each letter gets its own cube. Around its X axis, face n is placed with rotateX(n × -90deg) translateZ(s / 2): front, bottom, back and top carry the letters of CUBE, ROLL, CUBE, ROLL.
  2. Turning a cube n quarter turns forward brings face n to the front, upright, so every quarter turn swaps the word.
  3. One keyframe list: hold, turn, hold, turn… four turns make 360°, which looks exactly like 0°, so the loop has no seam. The easing is set per keyframe and only matters on the turns.
  4. 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.
  5. The word stands two by two, CU over BE, in a two-column grid: four cubes in one row are four and a half times wider than tall, too flat to fill the frame. Every length is a multiple of one base unit, --u, tied to the canvas, so the word is the same share of a gallery card, the editor and a recording canvas.
  6. 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.

Key techniques

  • face n: rotateX(n × -90deg) translateZ(s / 2)
  • hold + turn keyframes to 360deg
  • animation-delay from --i
  • backface-visibility: hidden

Copy-paste code

HTML 8 lines

<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>

CSS 114 lines

.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.5vmin;
  perspective: calc(800 * var(--u));
}

/* two by two, CU over BE: four cubes in a row draw four and a half times wider than they are
   tall, so at the 92vmin width limit the word stands well under the 40vmin floor */
.word {
  display: grid;
  grid-template-columns: repeat(2, auto);
  gap: calc(18 * var(--u)) calc(9 * var(--u)); /* more between the rows: seen from above, a row shows its top */
  transform-style: preserve-3d;
  transform: rotateX(-16deg) rotateY(-18deg); /* a fixed view from above and to the right */
}

.cube {
  --s: calc(44 * var(--u));
  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: calc(8 * var(--u));
  transform-style: preserve-3d;
}

/* letter faces: first word violet, second word pink */
.cube i {
  --c: #8b6cff;
  display: grid;
  place-items: center;
  /* an inner line plus a soft shade instead of a hard border: squeezed side-on, a 1-unit line
     breaks up, the shade survives */
  box-shadow:
    inset 0 0 0 calc(1 * var(--u)) color-mix(in srgb, var(--c), #fff 35%),
    inset 0 0 calc(8 * var(--u)) color-mix(in srgb, color-mix(in srgb, var(--c), #fff 35%) 35%, transparent);
  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 calc(26 * var(--u))/1 Inter, system-ui, sans-serif;
  font-style: normal;
  text-shadow: 0 calc(2 * var(--u)) 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 {
  box-shadow:
    inset 0 0 0 calc(1 * var(--u)) color-mix(in srgb, #8b6cff, #fff 10%),
    inset 0 0 calc(8 * var(--u)) color-mix(in srgb, color-mix(in srgb, #8b6cff, #fff 10%) 35%, transparent);
  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;
  /* 1 unit short of the edge: full size, it would touch the next face and show there as a dotted
     seam; much smaller leaves a channel along each edge you can see into */
  inset: calc(1 * var(--u));
  background: color-mix(in srgb, #8b6cff, #000 50%);
  transform: translateZ(calc(-5 * var(--u)));
}

.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);
  }
}
Standalone snippet · plain CSS, no build step, no dependencies.