Pure CSS Text effects #text #loop #shape #sass-loop

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.

  • 100 lines of CSS
  • 8 lines of HTML
  • No JavaScript
  • No dependencies
  • MIT licensed

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. 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 100 lines

.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);
  }
}

Sass source 105 lines

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