Building an Airport-Style Split-Flap Display With CSS 3D Transforms

Build a split-flap (Solari) display with CSS 3D rotateX transforms and JavaScript — full code breakdown plus React, Vue & Angular exports.
Building an Airport-Style Split-Flap Display With CSS 3D Transforms

You've seen this board before, even if you don't know its name: the departures sign at an old train station or airport, where every letter sits on its own mechanical card and flips through the alphabet with a satisfying clack until it lands on the right character. It's called a split-flap display, or a Solari board after the company that made them famous. The Split Flap Display snippet recreates the whole thing — the two-beat flip, the mechanical seam down the middle of each letter, the left-to-right ripple as a message updates — using nothing but layered <div>s, CSS 3D transforms, and a JavaScript timing engine. No video, no sprite sheet, no canvas.

Here's the effect live — type your own message, or just watch it cycle through presets:

Grab the code, or open the full editor with live HTML/CSS/JS panels: Split Flap Display on FWD Tools. It's plain HTML, CSS, and JavaScript, but the same editor has one-click export buttons for React, Vue, Angular, and React + Tailwind if you'd rather drop it into a component-based project than copy raw markup.

In this post I'll walk through the whole thing A to Z — what the effect actually is, where it earns its keep, and then the full build: how one character gets split across two static halves, the two-beat rotateX flip that recreates a physical flap, the step-through-the-alphabet cycling logic, and the staggered timing that makes a whole message ripple into place. By the end you'll understand it well enough to rebuild it from scratch or bend it into something of your own.

What the effect actually is

Strip away the mechanical styling and the mechanism is simple to state: the board is a row of fixed-size cells, one per character. Each cell can show any glyph from a fixed character set — a leading space, then A through Z, then 0 through 9 — and knows both its current character and its target character. When a new message arrives, every cell that needs to change doesn't jump straight there. It advances one character at a time through the set, playing a small flip animation at every step, until it reaches the target. Change a cell from A to G and you'll visibly see it flap through B, C, D, E, F before landing on G — exactly like the real thing.

Layered on top of that per-cell mechanism is a column stagger: when a whole message changes, the cells don't all start flipping at once. Each column's animation begins a little later than the one before it, so the message updates as a ripple sweeping left to right rather than a flat, simultaneous snap.

Where you'd actually use this

A split-flap board is a strong visual signature, and it earns its place in a specific set of spots:

  • Retro hero headers. A tagline or brand name that flaps into place on load reads as far more crafted and deliberate than text that's just already there.
  • Live status and departures boards. Show queue numbers, scores, or arrival times that visibly flip when the underlying value changes, rather than silently swapping text.
  • Countdown and launch reveals. Flap a date or product name into view to build anticipation before a reveal, the same way an airport board announces a new gate.
  • Game scoreboards and title screens. A score or level name that mechanically flips into place feels more tactile than a static number swap.
  • Brand microsites. A Solari board is instantly recognizable nostalgia — a strong, memorable signature for a campaign or product page.

In every one of these, the appeal is the same: a split-flap board behaves like a physical object with real inertia, which nothing about a plain text swap or a CSS fade naturally gives you.

The tech stack: CSS 3D transforms and the DOM, nothing else

This snippet uses nothing but the browser's built-in CSS 3D transform properties — perspective, rotateX, transform-origin, and backface-visibility — plus vanilla JavaScript driving setTimeout and requestAnimationFrame. No CDN script tags, no npm install, no build step. The entire illusion of a mechanical flap rests on rotating flat rectangles around a horizontal hinge and hiding whichever side is facing away from the viewer at any given moment.

If CSS 3D transforms are new territory for you, the core idea to hold onto is that perspective on a parent element creates a 3D viewing frustum for its children, so a rotateX on a child doesn't just squash it flat — it genuinely appears to tilt back into the screen, exactly like a real hinged card catching the light differently as it turns.

Building it, step by step

Step 1 — The HTML and CSS: five layered pieces per cell

The board itself is just a flex row of identical cells, each built from the same five-piece structure:

<div class="flap-cell">
  <div class="top"><span>&nbsp;</span></div>
  <div class="bot"><span>&nbsp;</span></div>
  <div class="divider"></div>
  <div class="flap-top-anim" style="display:none"><span>&nbsp;</span></div>
  <div class="flap-bot-anim" style="display:none"><span>&nbsp;</span></div>
</div>

.top and .bot are the resting state — they show the current character split across the seam and sit there whenever a cell isn't mid-flip. .divider is a thin line drawn across the exact middle of the cell to mimic the physical seam where a real flap card splits in two. .flap-top-anim and .flap-bot-anim are the two pieces that actually rotate during a flip, and they stay hidden (display: none) except for the brief moment they're animating.

Each cell carries perspective: 300px, which is what makes the child rotations that follow look like genuine 3D hinges instead of a flat squash. Both static halves clip their character with overflow: hidden: the .top half aligns its glyph to the top with align-items: flex-start so only the upper portion of the letter shows, and the .bot half aligns to the bottom with align-items: flex-end so only the lower portion shows. Stack them together and one full-height character reads correctly, split cleanly across the horizontal seam — exactly like a physical flap card.

Step 2 — The two-beat flip: rotating away, then rotating in

A real split-flap card flips in two beats: the old top leaf rotates down and away out of view, then the new bottom leaf rotates up into place. The code reproduces that with two elements rotating around opposite hinges. The animated top flap hinges at its bottom edge:

.flap-cell .flap-top-anim {
  transform-origin: bottom center;
  backface-visibility: hidden;
}

It starts flat at rotateX(0deg) showing the previous character, and rotates forward to rotateX(90deg) — folding away from the viewer edge-on until it disappears, revealing the static .top underneath (which has already been silently updated to the next character). The animated bottom flap hinges at its top edge instead:

.flap-cell .flap-bot-anim {
  transform-origin: top center;
  backface-visibility: hidden;
}

It starts folded away at rotateX(-90deg), already showing the next character edge-on and invisible, then rotates down to rotateX(0deg) to snap flat into its resting position. backface-visibility: hidden on both pieces is what keeps the reverse side of each rotating card from flashing into view mid-turn — without it, you'd briefly see a mirrored, wrong-looking glyph as each flap passes through its edge-on position.

The JavaScript sequences the two halves with short setTimeout delays around 55ms apart, so the top folds away first and the bottom snaps in right after — that small offset between the two rotations is what reads as a single convincing mechanical clack rather than two unrelated spins.

Step 3 — The double requestAnimationFrame trick

Before every flip, the code has to set each animated flap to its start transform with transitions disabled, then re-enable the transition and apply the end transform a moment later — otherwise the browser has nothing to animate from:

animTop.style.transition = 'none';
animTop.style.transform = 'rotateX(0deg)';

requestAnimationFrame(() => {
  requestAnimationFrame(() => {
    animTop.style.transition = 'transform 0.05s ease-in';
    animTop.style.transform = 'rotateX(90deg)';
  });
});

A single requestAnimationFrame isn't reliable enough here — browsers can still batch the "disable transition, set start value" write together with the very next write in the same paint, collapsing both into one and skipping the animation entirely. Nesting two calls guarantees a full frame has actually been painted with the start transform in place before the transition is turned back on and the end transform is applied. This double-rAF pattern is the standard, reliable way to trigger a CSS transition from JavaScript, and it's worth recognizing anywhere you see a snippet toggling transition: none before an animation.

Step 4 — Stepping through the character set instead of jumping

Each cell tracks a current index and a target index into a shared character set:

const CHARSET = ' ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';

The recursive animateStep function is the whole engine. It doesn't move a cell straight to its target — it advances current by exactly one position, using modular arithmetic to wrap back to the start of the charset if needed, plays one flip animation for that single step, and then calls itself again once the flip finishes:

function animateStep(cellObj) {
  if (cellObj.current === cellObj.target) {
    cellObj.animating = false;
    return;
  }
  cellObj.animating = true;
  cellObj.current = (cellObj.current + 1) % CHARSET.length;
  // ...play one flip from the old character to the new one...
  setTimeout(() => animateStep(cellObj), 10);
}

The recursion only stops once current catches up to target. That's the entire reason a cell changing from A to G visibly flaps through B, C, D, E, F on the way — it's not a scripted sequence, it's just this one function repeatedly asking "am I there yet?" and taking one more step if not.

Step 5 — Setting a message: eight cells, staggered

The board is a fixed number of cells (eight in this build). displayMessage pads or trims whatever text comes in to exactly that length:

function padMsg(msg) {
  return msg.toUpperCase().padEnd(NUM_CELLS, ' ').slice(0, NUM_CELLS);
}

Then for every cell it looks up the target character's index in CHARSET (falling back to a space for anything outside the supported set) and kicks off animateStep — but not all at once. Each cell's start is delayed by i * 40 milliseconds, where i is its column position:

padded.split('').forEach((ch, i) => {
  const idx = CHARSET.indexOf(ch) === -1 ? 0 : CHARSET.indexOf(ch);
  cells[i].target = idx;
  if (!cells[i].animating) {
    setTimeout(() => animateStep(cells[i]), i * 40);
  }
});

That single i * 40 offset is what produces the left-to-right ripple across the whole board — without it, every cell would start flipping in the exact same instant, and the board would lose the sense of a mechanical wave passing over it. A per-cell animating flag guards against a second message arriving mid-flip from starting a duplicate animation chain on the same cell.

Step 6 — Keeping the board alive: presets and auto-cycling

A row of preset buttons is generated from a small array of sample messages, each wired to call displayMessage on click. A text input lets visitors type up to eight characters of their own, committing on the Set button or the Enter key. And a setInterval quietly rotates through the presets every few seconds on its own, so the board is never sitting frozen — much like a real departures sign that's always mid-update for something.

Customizing it for your own project

A few knobs worth knowing about if you're adapting this:

  • Character set — add symbols, punctuation, or lowercase letters to CHARSET. The cycling logic uses indexOf and modular arithmetic, so it adapts to any length automatically, though a longer set means more intermediate flips for characters far apart in the string.
  • Flip speed — the transition durations on the animated flaps (around 0.05s each) and the 55ms setTimeout gap between the top and bottom beats together control how snappy or leisurely each individual flip feels.
  • Ripple timing — the i * 40 per-column stagger controls how pronounced the left-to-right wave looks; raise it for a slower, more deliberate sweep, or drop it toward zero for a near-simultaneous update.
  • Board sizeNUM_CELLS controls how many characters the board holds; the cell-creation loop and padding logic both scale automatically.

Using it in React, Vue, or Angular

The one rule that matters when porting this into a component framework is: create the cells and kick off any initial cycling inside a mount lifecycle hook — useEffect in React, onMounted in Vue, ngAfterViewInit in Angular — never at module or render time, since the DOM elements the animation manipulates directly need to exist first. Keep each cell's current/target/animating state in a ref or plain array rather than component state, since re-rendering the whole board on every single character flip would fight the recursive setTimeout chain and stutter the animation. On unmount, clear any active setInterval auto-cycling and let in-flight setTimeout chains run out naturally rather than trying to cancel mid-flip. When the message is passed in as a prop, call the same displayMessage-equivalent logic whenever it changes, exactly like the Set button does in the vanilla version.

The FWD Tools snippet editor has one-click exports for all of this already built if you'd rather skip writing the conversion by hand — grab the React, Vue, Angular, or Tailwind version directly from the snippet page.

Build, understand, optimize, and extend it with AI

You don't have to trace every hinge rotation and timing offset by hand. Paste the snippet's HTML, CSS, and JS into an AI coding assistant like Claude and ask it to walk through exactly why the two animated flaps need opposite transform-origin values, or why a single requestAnimationFrame isn't enough to reliably trigger the transition. The same assistant can help optimize it — for instance, checking whether the recursive animateStep calls could be replaced with a single driving loop for boards with many more cells, or whether the per-step setTimeout chain introduces any visible jitter under load. It's just as useful for extending the effect: ask it to add a sound effect on each flap, wire the board up to a live data feed like a real countdown or ticket queue, or support a taller multi-row board for longer messages. Treat the code less like a finished artifact and more like a starting point for a conversation.

Prompt to recreate it

Copy this into your AI assistant of choice to build the effect from scratch, or as a jumping-off point for your own variant:

Build an airport-style "split-flap display" (Solari board) in plain HTML, CSS, and JavaScript using only CSS 3D transforms — no canvas, no video, no sprite images, no libraries.

Requirements:
- A row of fixed-size cells, one per character, each with CSS `perspective` set on the cell so child rotations look like real 3D hinges rather than a flat squash.
- Each cell is built from five layered pieces: a static top half and static bottom half showing the resting character (clipped with overflow hidden, top half aligned to its top edge, bottom half aligned to its bottom edge, so together they show one full-height glyph split across a seam), a thin divider line across the middle, and two animated flap elements that are hidden except during a flip.
- The top animated flap must have `transform-origin: bottom center` and rotate from rotateX(0deg) to rotateX(90deg) to fold away and reveal the character underneath. The bottom animated flap must have the opposite hinge, `transform-origin: top center`, and rotate from rotateX(-90deg) up to rotateX(0deg) to snap the new character into place. Both need `backface-visibility: hidden` so their reverse side never flashes into view mid-rotation.
- Every cell tracks a current index and a target index into a shared character set (a leading space, then A–Z, then 0–9). A recursive step function must advance the current index by exactly one position per call (wrapping with modular arithmetic), play one two-beat flip animation for that single step, and call itself again until current equals target — so a cell changing from A to G visibly flaps through every letter in between rather than jumping.
- Before triggering each rotation, apply the start transform with `transition: none`, then re-enable the transition and apply the end transform inside two nested requestAnimationFrame calls (not just one), so the browser reliably paints the start state before the transition begins.
- When a new message is set, stagger each column's animation start by an increasing delay (e.g. columnIndex * 40ms) so the whole board updates as a left-to-right ripple instead of all cells flipping simultaneously.
- Add a text input and a Set button to commit a custom message, plus a row of preset buttons and a setInterval that auto-cycles through presets every few seconds.

Final thought

What makes this snippet worth studying isn't the retro charm — it's the underlying pattern: a convincing mechanical animation is often just two flat rectangles rotating around opposite hinges with their reverse sides hidden, sequenced a beat apart. Swap the hinge orientation, the timing, or what triggers a step, and the exact same technique reproduces flip clocks, page-turn effects, or any UI element that needs to feel like it has physical weight and inertia rather than just fading between two states.

Grab the full HTML, CSS, and JS — or export it straight to React, Vue, Angular, or Tailwind — at Split Flap Display on FWD Tools. It's free, runs entirely in your browser, and needs no sign-up. Browse more CSS 3D and animation effects like this one at FWD Tools UI Snippets — Animations.

About the author

Puneet Sharma
Puneet Sharma is a freelance web developer, tech writer, and blogger who shares tutorials, technology news, and practical resources for developers. He is also the founder of FWD Tools.

Post a Comment