How to make a Solari board effect with Tailwind CSS and JavaScript

December 2025
tailwind, javascript

When visiting big train stations or airports, you might've seen a split-flap display (or Solari board). For those who don’t know what a Solari board is, it’s a board made up of small squares, each displaying a single letter or number. The cards “flip” repeatedly until they reach a target letter or number. Something like this:

click on the "Change Destination" button to see the next destination

Flight To:
Flight Number:
Gate:

Let's try building this effect with Tailwind CSS and JavaScript :D

How does it work?

Now, we first need to understand how an actual Solari board works. You can watch this video on YouTube that provides a very detailed explanation of the card movements (video credits to scottbez1). Basically, each character is split into two separate flaps. Each half of the card has a separate character on it. The top half is the one that falls down - the one that's dynamic. As the top half falls down, it visually aligns with the bottom half of the next character, forming a single complete letter.

Try using the slider down below to see how this works. When we move the slider from right to left, we can see the top half unfolding toward the viewer. In the example below, the first letter is A and the second letter is B.

B
A
B
A

Pretty cool, right? The top half of the card contains the half of the next letter. So in this case, the top half of the card contains the bottom part of the letter B.

Here's a more intuitive example with color-coded sections:

B
A
B
A

As you can see, there are four sections involved:

  • orange: The top half of the current letter.
  • green: The bottom half of the current letter.
  • blue: The top half of the next letter.
  • red: The bottom half of the next letter.

The sections that actually move are the orange and red ones.

The entire animation is a combination of a lot of these components. Let's try making it step by step.

The static section

From the example above, the blue and green sections are static. We can create this relatively easily.

B
A
<div className="flex w-full flex-col items-center justify-center gap-4 rounded-md bg-gray-100 px-4 py-6">
  <div className="relative h-12 w-7 rounded-md select-none">
    {/* Static Top Section */}
    <div
      className="absolute inset-0 flex items-center justify-center rounded-md bg-blue-300 text-2xl font-extrabold text-white"
      style={{ clipPath: "polygon(0 0, 100% 0, 100% 50%, 0% 50%)" }}
    >
      B
    </div>

    {/* Static Bottom Section */}
    <div
      className="absolute inset-0 flex items-center justify-center rounded-md bg-green-300 text-2xl font-extrabold text-white"
      style={{
        clipPath: "polygon(0 50%, 100% 50%, 100% 100%, 0% 100%)",
      }}
    >
      A
    </div>
  </div>
</div>

The key here is to use CSS clip-path to clip out the parts that we don't want to see. In this case, we clipped the bottom half of the letter B and the top part of the letter A.

The dynamic section

This is the tricky part. We want the red and orange sections move simultaneously together. The key is using the rotateX() and backface-hidden properties in CSS. Here's a quick explanation of these two properties. The rotateX() property is used to rotate the element around the X-axis. Try using the slider down below to see how this works.

A
Rotate X: 0deg

If the rotateX() value increases (from 0deg to 180deg), the top part of the element will rotate away from the viewer. Conversely, if the rotateX() value decreases (from 180deg to 0deg), the top part of the element will rotate toward the viewer.

Adding on top of this, we can use the backface-hidden property to hide the back face of an element.

A
Rotate X: 0deg

With a combination of these two properties, we can create the dynamic section of the Solari board effect. First, let's add the orange section.

B
A
A
<div
  className="absolute inset-0 flex items-center justify-center rounded-md bg-orange-300 text-2xl font-extrabold text-white backface-hidden"
  style={{
    clipPath: "polygon(0 0, 100% 0, 100% 50%, 0% 50%)",
    transform: `rotateX(${rotateX - 180}deg)`,
  }}
>
  A
</div>

We start by setting the rotateX() value to 0deg. And when see slide the slider to the left, the value decreases, making the rotateX() value to -180deg. Because it goes to a negative value, the top part of the element will rotate towards the viewer. Also, by adding the backface-hidden property, we can hide the back face of the element which makes the orange section disappear after it passes the -90deg mark.

The red section is similar to the orange section, but with the rotateX() value first set to 180deg, moving towards 0deg. Because we set it to 180deg, we don't see anything at first, but once it hits the 90deg mark, the red section appears.

B
A
B

Combining these two rotations together, we can create a sort of "illusion" where it seems as if the orange and red sections are moving together.

B
A
B
A

That's it! We've created the dynamic section of the Solari board effect.

While trying out the interactive examples, you might've noticed the dynamic sections moving realistically when rotating. This is because of the perspective property in CSS. You can read more about it here

B
A
B
A
Change rotate valueChange perspective value (px): 500

Creating the animation

Let's create the actual animation. One tricky part is keeping track of the current and next letters correctly. Because we know where each of the current and next letters should go in the 4 sections, we can set the current and next letters accordingly. We can predefine a set of characters to use as random placeholders before revealing the target letter.

const RANDOM_CHARACTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";

const getRandomCharacter = () => {
  return RANDOM_CHARACTERS[
    Math.floor(Math.random() * RANDOM_CHARACTERS.length)
  ];
};

// ----------------------------------

const animTimer = setTimeout(() => {
  setDisplay((prev) => {
    const nextCount = prev.triggerCount + 1;

    const nextChar = getRandomCharacter();

    return {
      current: prev.next,
      next: nextChar,
      triggerCount: nextCount,
    };
  });
}, 1000);

The animation feels very smooth! I also added a seam line in the middle of the card to make it look more realistic.

<div className="absolute top-1/2 h-px w-full -translate-y-1/2 bg-gray-400" />

All we need to do now is to add logic that will stop the animation after a certain number of flips.

You can change this logic as you like. For example, you can loop the random characters in alphabetical order and make the animation stop when it hits the target letter. However, for my example, I'm just randomly selecting characters from the predefined set of characters.

You can also add delays for each flipping card. For example,

  • the first card flips after base 8 flips
  • the second card flips an additional 3 flips (total 11 flips)
  • the third card flips an additional 3 flips (total 14 flips)
  • ... and so on.
// Stop the animation if we have reached the maximum number of flips
if (triggerCount === MAX_FLIPS) {
  setTimeout(() => {
    setDisplay((prev) => ({ ...prev, isFinished: true }));
  }, 0);
  return;
}

const animTimer = setTimeout(() => {
  setDisplay((prev) => {
    const nextCount = prev.triggerCount + 1;

    // LOGIC: Determining the 'next' character (the one about to flip down)
    // 1. If we are at (MAX - 1), the character falling down MUST be the target letter.
    // 2. Otherwise, it's just another random character.
    const nextChar =
      nextCount === MAX_FLIPS - 1 ? letter : getRandomCharacter();

    return {
      current: prev.next, // The previous 'next' lands and becomes 'current'
      next: nextChar, // The new falling flap
      triggerCount: nextCount,
      isFinished: false,
    };
  });
}, 1000);

As mentioned above, this part of the code is entirely up to you. As long as you can set the current and next letters correctly, you can create a very custom animation with various delays and other effects :D