Pure CSS Text effects #text #loop #shape

Rotating word prism in pure CSS

Four words on the four long sides of a prism that turns a quarter at a time, pausing on each. After 360° it is back where it started, so the loop has no seam.

  • 64 lines of CSS
  • 11 lines of HTML
  • No JavaScript
  • No dependencies
  • MIT licensed

How it works

  1. Face n is turned n quarter turns backwards around X, then pushed out with translateZ by half the prism’s depth — the same "turn, then push" recipe as a basic cube, just on one axis.
  2. The whole prism is pulled back by half its depth (translateZ(-18px)) so the face currently in front sits exactly at z = 0 and stays crisp.
  3. The keyframes hold on each face for a stretch, then turn 90deg with their own cubic-bezier — a snappy, springy turn rather than a constant spin.
  4. backface-visibility: hidden stops a word from showing mirrored through the box while it turns.
  5. 360deg looks exactly like 0deg, so the loop has no seam.

Key techniques

  • rotateX(n × -90deg) translateZ(h / 2)
  • hold + turn keyframes
  • backface-visibility: hidden
  • per-keyframe easing

Copy-paste code

HTML 11 lines

<div class="scene">
  <div class="wordcube">
    <span>We make</span>
    <span class="prism">
      <span style="--i:0;--c:#8b6cff">design</span>
      <span style="--i:1;--c:#2ee6d6">code</span>
      <span style="--i:2;--c:#ff4d9d">motion</span>
      <span style="--i:3;--c:#ffb547">brands</span>
    </span>
  </div>
</div>

CSS 64 lines

.scene {
  perspective: 800px;
}

.wordcube {
  display: flex;
  align-items: center;
  gap: 9px;
  color: #eceefb;
  font-size: 21px;
  font-weight: 800;
  white-space: nowrap;
  transform-style: preserve-3d;
}

.prism {
  position: relative;
  display: inline-block;
  width: 104px;
  height: 36px; /* face height = prism depth: its cross-section is a square */
  transform-style: preserve-3d;
  animation: cube-turn 9s infinite;
}

.prism span {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  padding-left: 12px;
  border: 1px solid color-mix(in srgb, var(--c) 80%, transparent);
  border-radius: 8px;
  background: color-mix(in srgb, var(--c) 26%, transparent);
  box-shadow: inset 0 0 18px color-mix(in srgb, var(--c) 32%, transparent);
  color: var(--c);
  backface-visibility: hidden;
  transform: rotateX(calc(var(--i) * -90deg)) translateZ(18px);
}

@keyframes cube-turn {
  0%, 19% {
    transform: translateZ(-18px) rotateX(0deg);
    animation-timing-function: cubic-bezier(0.6, -0.3, 0.3, 1.3);
  }

  25%, 44% {
    transform: translateZ(-18px) rotateX(90deg);
    animation-timing-function: cubic-bezier(0.6, -0.3, 0.3, 1.3);
  }

  50%, 69% {
    transform: translateZ(-18px) rotateX(180deg);
    animation-timing-function: cubic-bezier(0.6, -0.3, 0.3, 1.3);
  }

  75%, 94% {
    transform: translateZ(-18px) rotateX(270deg);
    animation-timing-function: cubic-bezier(0.6, -0.3, 0.3, 1.3);
  }

  100% {
    transform: translateZ(-18px) rotateX(360deg);
  }
}

Sass source 70 lines

$h: 36px; // height of a face = depth of the prism (its cross-section is a square)

.d-wordcube {
  display: flex;
  align-items: center;
  gap: 9px;
  color: var(--text);
  font-size: 21px;
  font-weight: 800;
  white-space: nowrap;
  transform-style: preserve-3d;

  &__prism {
    position: relative;
    width: 104px;
    height: $h;
    transform-style: preserve-3d;
    animation: d-wordcube-turn 9s infinite;
  }

  // Face n is turned n quarter turns BACKWARDS around X, then pushed out by half the depth:
  // front, bottom, back, top. Turning the prism forwards brings them up one after another.
  &__prism > span {
    position: absolute;
    inset: 0;
    display: flex;
    align-items: center;
    padding-left: 12px;
    border: 1px solid color-mix(in srgb, var(--c) 80%, transparent);
    border-radius: 8px;
    background: color-mix(in srgb, var(--c) 26%, transparent);
    box-shadow: inset 0 0 18px color-mix(in srgb, var(--c) 32%, transparent);
    color: var(--c);
    backface-visibility: hidden; // never show a word mirrored through the prism
    transform: rotateX(calc(var(--i) * -90deg)) translateZ($h * 0.5);
  }
}

// Hold, quarter turn, hold... The easing is set per keyframe and only matters on the turns.
// The prism is pulled back by half its depth so the front face sits at z = 0 and stays crisp.
// 360deg looks exactly like 0deg, so the loop is seamless.
@keyframes d-wordcube-turn {
  0%,
  19% {
    transform: translateZ($h * -0.5) rotateX(0deg);
    animation-timing-function: cubic-bezier(0.6, -0.3, 0.3, 1.3);
  }

  25%,
  44% {
    transform: translateZ($h * -0.5) rotateX(90deg);
    animation-timing-function: cubic-bezier(0.6, -0.3, 0.3, 1.3);
  }

  50%,
  69% {
    transform: translateZ($h * -0.5) rotateX(180deg);
    animation-timing-function: cubic-bezier(0.6, -0.3, 0.3, 1.3);
  }

  75%,
  94% {
    transform: translateZ($h * -0.5) rotateX(270deg);
    animation-timing-function: cubic-bezier(0.6, -0.3, 0.3, 1.3);
  }

  100% {
    transform: translateZ($h * -0.5) rotateX(360deg);
  }
}
Standalone snippet · plain CSS, no build step, no dependencies.