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.

  • 129 lines of CSS
  • 23 lines of HTML
  • 46 lines of JS
  • No dependencies
  • Snippet under MIT
GitHub

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, and it carries on into the second row, so the board cascades.
  6. Every length is a multiple of one base unit, --u, tied to the canvas: a cell is 21 × 34 of them, so the board is the same share of a gallery card, the editor and a recording canvas.

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

<div class="board" role="img" aria-label="Split-flap departure board cycling through city names">
  <div class="board-head"><span>Departures</span><b>Gate 3D</b></div>
  <div class="board-row">
    <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 class="board-row">
    <span class="cell" style="--i:8"><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:9"><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:10"><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:11"><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:12"><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:13"><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:14"><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:15"><i class="cell-top"></i><i class="cell-bottom"></i><i class="cell-fall"></i><i class="cell-land"></i></span>
  </div>
</div>

CSS 129 lines

/* One row of eight cells is a shape the band cannot hold: it draws three times wider than it
   is tall, so at the 92vmin width limit the board stands 30vmin high, under the 40vmin floor.
   Two destinations is what a departure board shows anyway. */
.board {
  /* one base unit: every length below is a multiple of it, so the board is the same share of a
     card, the editor, a full screen and a recording canvas */
  --u: 0.40vmin;
  display: grid;
  gap: calc(8 * var(--u));
  padding: calc(12 * var(--u)) calc(12 * var(--u)) calc(10 * var(--u));
  border: calc(1 * var(--u)) solid #3a4070;
  border-radius: calc(12 * var(--u));
  background: linear-gradient(160deg, #211c3c, #0b0d18 70%);
  box-shadow: 0 calc(18 * var(--u)) calc(30 * var(--u)) calc(-18 * var(--u)) rgb(0 0 0 / 0.8);
}

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

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

.board-row {
  display: flex;
  gap: calc(3 * var(--u));
}

.cell {
  position: relative;
  width: calc(21 * var(--u));
  height: calc(34 * var(--u));
  perspective: calc(140 * var(--u)); /* its own close camera: a 17-unit leaf needs a strong one to read as falling */
}

/* the slit between the two halves */
.cell::after {
  content: '';
  position: absolute;
  top: calc(50% - 0.5 * var(--u));
  right: 0;
  left: 0;
  height: calc(1 * var(--u));
  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 calc(22 * var(--u))/calc(34 * var(--u)) 'JetBrains Mono', ui-monospace, monospace;
  text-align: center;
}

.cell-top,
.cell-fall {
  top: 0;
  border-radius: calc(4 * var(--u)) calc(4 * var(--u)) 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 calc(4 * var(--u)) calc(4 * var(--u));
  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 46 lines

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

// one list of cells per row; the two rows show two destinations, one behind the other
var rows = Array.prototype.map.call(document.querySelectorAll('.board-row'), function (row) {
  return Array.prototype.map.call(row.querySelectorAll('.cell'), function (cell) {
    return { cell: cell, leaves: cell.querySelectorAll('i'), char: ' ' }; // top, bottom, fall, land
  });
});

function show(cells, 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;
rows.forEach(function (cells, r) {
  show(cells, WORDS[r % WORDS.length], false);
});

// The site pauses a model by marking the frame's <html data-paused>; a visitor may ask for less
// motion. Either way the board holds its word.
var calm = matchMedia('(prefers-reduced-motion: reduce)');
setInterval(function () {
  if (document.documentElement.hasAttribute('data-paused') || calm.matches) return;
  index = (index + 1) % WORDS.length;
  rows.forEach(function (cells, r) {
    show(cells, WORDS[(index + r) % WORDS.length], true);
  });
}, 2600);
Standalone snippet · plain CSS, no build step, no dependencies.