CSS + JS Text effects #text #loop #generated

Split-flap board in CSS + JavaScript

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

  • 123 lines of CSS
  • 13 lines of HTML
  • 34 lines of JS
  • No dependencies
  • MIT licensed

How it works

  1. Every cell is four half-height leaves: static top/bottom show the current split, and fall/land are the ones that animate between the old and new character.
  2. All four leaves read the same letter from content: attr(data-c), so JS never touches a text node — it only ever writes a data-c attribute.
  3. To replay a CSS animation, JS removes the .is-flip class, reads a layout property to force the browser to flush styles, then re-adds the class.
  4. animation-fill-mode: both matters: outside its delay the falling leaf sits parked edge-on at rotateX(-90deg), and the landing leaf holds its open pose until its own delay ends.
  5. --i staggers each cell by 45ms so the flips ripple across the row instead of firing together.

Key techniques

  • two hinged half-leaves per cell
  • content: attr(data-c)
  • restart animation by re-adding a class
  • animation-fill-mode: both

Copy-paste code

HTML 13 lines

<div class="board">
  <div class="board-head"><span>Departures</span><b>Gate 3D</b></div>
  <div class="board-row" role="img" aria-label="Split-flap display cycling through city names">
    <span class="cell" style="--i:0"><i class="cell-top"></i><i class="cell-bottom"></i><i class="cell-fall"></i><i class="cell-land"></i></span>
    <span class="cell" style="--i:1"><i class="cell-top"></i><i class="cell-bottom"></i><i class="cell-fall"></i><i class="cell-land"></i></span>
    <span class="cell" style="--i:2"><i class="cell-top"></i><i class="cell-bottom"></i><i class="cell-fall"></i><i class="cell-land"></i></span>
    <span class="cell" style="--i:3"><i class="cell-top"></i><i class="cell-bottom"></i><i class="cell-fall"></i><i class="cell-land"></i></span>
    <span class="cell" style="--i:4"><i class="cell-top"></i><i class="cell-bottom"></i><i class="cell-fall"></i><i class="cell-land"></i></span>
    <span class="cell" style="--i:5"><i class="cell-top"></i><i class="cell-bottom"></i><i class="cell-fall"></i><i class="cell-land"></i></span>
    <span class="cell" style="--i:6"><i class="cell-top"></i><i class="cell-bottom"></i><i class="cell-fall"></i><i class="cell-land"></i></span>
    <span class="cell" style="--i:7"><i class="cell-top"></i><i class="cell-bottom"></i><i class="cell-fall"></i><i class="cell-land"></i></span>
  </div>
</div>

CSS 123 lines

.board {
  display: grid;
  gap: 8px;
  padding: 12px 12px 10px;
  border: 1px solid #3a4070;
  border-radius: 12px;
  background: linear-gradient(160deg, #211c3c, #0b0d18 70%);
  box-shadow: 0 18px 30px -18px rgb(0 0 0 / 0.8);
}

.board-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  color: #949bc0;
  font-size: 9px;
  font-weight: 700;
  letter-spacing: 0.16em;
  text-transform: uppercase;
}

.board-head b {
  color: #ffb547;
  font-weight: 800;
}

.board-row {
  display: flex;
  gap: 3px;
}

.cell {
  position: relative;
  width: 21px;
  height: 34px;
  perspective: 140px; /* its own close camera: a 17px leaf needs a strong one to read as falling */
}

/* the slit between the two halves */
.cell::after {
  content: '';
  position: absolute;
  top: calc(50% - 0.5px);
  right: 0;
  left: 0;
  height: 1px;
  background: #05060c;
}

.cell i {
  position: absolute;
  right: 0;
  left: 0;
  height: 50%;
  overflow: hidden; /* a leaf, so clipping here flattens nothing */
  backface-visibility: hidden;
}

/* the whole character, twice the leaf's height; the leaf shows one half of it */
.cell i::before {
  content: attr(data-c);
  position: absolute;
  right: 0;
  left: 0;
  height: 200%;
  color: #f4f1e6;
  font: 800 22px/34px ui-monospace, monospace;
  text-align: center;
}

.cell-top,
.cell-fall {
  top: 0;
  border-radius: 4px 4px 0 0;
  background: #1a1e36;
}

.cell-top::before,
.cell-fall::before {
  top: 0;
}

/* bottom halves: slightly lighter, as if lit from above */
.cell-bottom,
.cell-land {
  bottom: 0;
  border-radius: 0 0 4px 4px;
  background: #20254a;
}

.cell-bottom::before,
.cell-land::before {
  bottom: 0;
}

.cell-fall {
  transform: rotateX(-90deg); /* parked edge-on = invisible */
  transform-origin: 50% 100%;
}

.cell-land {
  transform-origin: 50% 0;
}

/* "both": during its delay the landing leaf waits edge-on at 90deg, afterwards it stays down
   and simply covers the static bottom half */
.cell.is-flip .cell-fall {
  animation: flap-fall 0.17s ease-in calc(var(--i) * 45ms) both;
}

.cell.is-flip .cell-land {
  animation: flap-land 0.17s ease-out calc(var(--i) * 45ms + 0.17s) both;
}

@keyframes flap-fall {
  from { transform: rotateX(0deg); }
  to   { transform: rotateX(-90deg); }
}

@keyframes flap-land {
  from { transform: rotateX(90deg); }
  to   { transform: rotateX(0deg); }
}

JS 34 lines

var WORDS = ['NEW YORK', 'HELSINKI', 'LISBON', 'BANGKOK', 'SAN JOSE', 'CSS 3D'];

var cells = Array.prototype.map.call(document.querySelectorAll('.cell'), function (cell) {
  return { cell: cell, leaves: cell.querySelectorAll('i'), char: ' ' }; // top, bottom, fall, land
});

function show(word, animate) {
  var text = word + '        '; // pad so every cell always has a character
  cells.forEach(function (c, i) {
    var next = text[i];
    if (next === c.char && animate) return;
    var top = c.leaves[0];
    var bottom = c.leaves[1];
    var fall = c.leaves[2];
    var land = c.leaves[3];
    top.dataset.c = next; // revealed as the old top half falls
    land.dataset.c = next; // lands on top of the old bottom half
    fall.dataset.c = c.char;
    bottom.dataset.c = c.char;
    c.char = next;
    if (!animate) return;
    // restart the CSS animation: drop the class, force a style flush, add it again
    c.cell.classList.remove('is-flip');
    void c.cell.offsetWidth;
    c.cell.classList.add('is-flip');
  });
}

var index = 0;
show(WORDS[0], false);
setInterval(function () {
  index = (index + 1) % WORDS.length;
  show(WORDS[index], true);
}, 2600);

Sass source 144 lines

// Split-flap board. Every cell is four half-height leaves showing the top or bottom half of a
// character (the character itself comes from data-c, printed by ::before):
//   top     static, already shows the NEW character
//   bottom  static, still shows the OLD character
//   fall    top half of the OLD character, hinged on the middle, falls forward 0 -> -90deg
//   land    bottom half of the NEW character, hinged on the middle, lands 90deg -> 0
// JS only writes the data-c attributes and restarts the animation by re-adding .is-flip.
$w: 21px;
$h: 34px;
$dur: 0.17s;

.d-flaptext {
  display: grid;
  gap: 8px;
  padding: 12px 12px 10px;
  border: 1px solid var(--border-strong);
  border-radius: 12px;
  background: linear-gradient(160deg, color-mix(in srgb, var(--accent) 16%, #0b0d18), #0b0d18 70%);
  box-shadow: 0 18px 30px -18px rgb(0 0 0 / 0.8);

  &__head {
    display: flex;
    align-items: center;
    justify-content: space-between;
    color: #949bc0;
    font-size: 9px;
    font-weight: 700;
    letter-spacing: 0.16em;
    text-transform: uppercase;

    b {
      color: var(--warm);
      font-weight: 800;
    }
  }

  &__row {
    display: flex;
    gap: 3px;
  }

  &__cell {
    position: relative;
    width: $w;
    height: $h;
    // Its own, close camera: a 17px leaf needs a strong perspective to read as falling.
    perspective: 140px;

    // the slit between the two halves
    &::after {
      content: '';
      position: absolute;
      top: calc(50% - 0.5px);
      right: 0;
      left: 0;
      height: 1px;
      background: #05060c;
    }

    i {
      position: absolute;
      right: 0;
      left: 0;
      height: 50%;
      overflow: hidden; // a leaf, so clipping here flattens nothing
      backface-visibility: hidden;

      // The whole character, twice the leaf's height; the leaf shows one half of it.
      &::before {
        content: attr(data-c);
        position: absolute;
        right: 0;
        left: 0;
        height: 200%;
        color: #f4f1e6;
        font: 800 22px / #{$h} var(--mono, ui-monospace, monospace);
        text-align: center;
      }
    }
  }

  // top halves
  &__top,
  &__fall {
    top: 0;
    border-radius: 4px 4px 0 0;
    background: #1a1e36;

    &::before {
      top: 0;
    }
  }

  // bottom halves: slightly lighter, as if lit from above
  &__bottom,
  &__land {
    bottom: 0;
    border-radius: 0 0 4px 4px;
    background: #20254a;

    &::before {
      bottom: 0;
    }
  }

  &__fall {
    transform: rotateX(-90deg); // parked edge-on = invisible
    transform-origin: 50% 100%;
  }

  &__land {
    transform-origin: 50% 0;
  }

  // `both`: during its delay the landing leaf waits edge-on at 90deg, afterwards it stays down
  // and simply covers the static bottom half.
  &__cell.is-flip &__fall {
    animation: d-flaptext-fall $dur ease-in calc(var(--i) * 45ms) both;
  }

  &__cell.is-flip &__land {
    animation: d-flaptext-land $dur ease-out calc(var(--i) * 45ms + #{$dur}) both;
  }
}

@keyframes d-flaptext-fall {
  from {
    transform: rotateX(0deg);
  }

  to {
    transform: rotateX(-90deg);
  }
}

@keyframes d-flaptext-land {
  from {
    transform: rotateX(90deg);
  }

  to {
    transform: rotateX(0deg);
  }
}
Standalone snippet · plain CSS, no build step, no dependencies.