Pure CSS Text effects #loop #text #wide

Letter wave in pure CSS

Every letter does the same flip; the stagger turns it into a wave.

  • 53 lines of CSS
  • 14 lines of HTML
  • No JavaScript
  • No dependencies
  • Snippet under MIT
GitHub

How it works

  1. Wrap every letter in a <span> with display: inline-block — transforms are ignored on plain inline boxes.
  2. All letters share one keyframe animation: lift toward the camera while flipping 360°.
  3. A letter has two sides, like a card: its own text with backface-visibility: hidden is the front, and a ::after with content: attr(data-c), turned rotateY(180deg) and hidden from behind too, is the back. Halfway through the flip you read the back, the right way round, never a mirrored letter.
  4. animation-delay: calc(var(--i) * 0.12s) offsets each letter, and the offsets read as a travelling wave.
  5. Put the real word in aria-label and hide the spans from screen readers, or it is read letter by letter.
  6. --i carries on from WAVE into 3D, so the wave rolls across the gap between the words.
  7. The word stands on one line, as a word does. Seven letters in a row are about four times wider than they are tall, so it is sized by its width, like the other wide models in the gallery, not by its height: it is marked wide in its gallery entry.
  8. Every length is a multiple of one base unit, --u, the lift toward the camera included, so the word is the same share of a gallery card, the editor and a recording canvas.

Key techniques

  • inline-block letters
  • animation-delay from --i
  • rotateY flip

Copy-paste code

HTML 14 lines

<div class="scene">
  <h1 class="wave" aria-label="WAVE 3D">
    <span class="line">
      <span style="--i:0" data-c="W" aria-hidden="true">W</span>
      <span style="--i:1" data-c="A" aria-hidden="true">A</span>
      <span style="--i:2" data-c="V" aria-hidden="true">V</span>
      <span style="--i:3" data-c="E" aria-hidden="true">E</span>
    </span>
    <span class="line">
      <span style="--i:4" data-c="3" aria-hidden="true">3</span>
      <span style="--i:5" data-c="D" aria-hidden="true">D</span>
    </span>
  </h1>
</div>

CSS 53 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.26vmin;
  perspective: calc(600 * var(--u));
}

/* One line: WAVE, a word's gap, 3D. Seven letters in a row are about four times wider than they
   are tall, so the word is sized by its width (it is marked 'wide'): the unit puts it near the
   width the gallery's other wide models have */
.wave {
  display: flex;
  align-items: center;
  gap: calc(22 * var(--u));
  margin: 0;
  font: 900 calc(64 * var(--u))/1 Inter, system-ui;
  transform-style: preserve-3d;
}

.wave .line {
  display: flex;
  gap: calc(2 * var(--u));
  transform-style: preserve-3d;
}

/* each letter is a card with the letter on both sides: the span's own text is the front and
   hides once it turns away, and ::after is the same letter turned round to face the back, so a
   letter mid-flip never shows mirrored */
.wave .line span {
  position: relative;
  display: inline-block;
  /* one lightness for every hue, 3:1 or more on both stages (large text): hsl at 70% was 2.3:1 on
     the light one */
  color: oklch(62% 0.22 calc(290 + var(--i) * 18));
  transform-style: preserve-3d;
  backface-visibility: hidden;
  animation: wave 2.8s ease-in-out infinite;
  animation-delay: calc(var(--i) * 0.12s);
}

.wave .line span::after {
  content: attr(data-c);
  position: absolute;
  inset: 0;
  backface-visibility: hidden;
  transform: rotateY(180deg);
}

@keyframes wave {
  0%        { transform: translateZ(0)                 rotateY(0deg); }
  22%       { transform: translateZ(calc(60 * var(--u))) rotateY(180deg); }
  45%, 100% { transform: translateZ(0)                 rotateY(360deg); }
}
Standalone snippet · plain CSS, no build step, no dependencies.