Splitting Time
A “simple” split-flap display built using pure HTML, CSS, and JavaScript.
What is a split-flap display?
A split flap display is a type of display where each character—or, less commonly, entire words—is split in half vertically on flaps arranged around a drum. The drum rotates, causing the flaps to flip over revealing the next character in the sequence. If none of that makes sense, the this YouTube video from scottbez1 does a pretty good job of explaining it: https://youtu.be/UAQJJAQSg_g.
These were commonly used in transit hubs like train stations and airports to display flight updates or arrival times due to their low power draw and large, modern LCD screens were commercially viable.
Why?
I created this project because of two things happening almost simultaneously:
- The YouTube video above randomly showed up in my feed and
- It was a slow Saturday morning and I wanted to see if I could replicate how things looked on my own.
Short version long: This is possible, but it was a little more complicated than I had originally thought. Because JavaScript is JavaScript, this ended up taking about a week of spare time.
I also wanted to see the content on the display update on a fairly frequent basis, and since the time is an ever-advancing thing, that's what this will display.
The plan
I wanted this display to be flexible (At initialization, I can tell each block in the sequence the characters or words it could display), modular (Easy to plug in anywhere I wanted to plug it into), and mimic the way a real split flap display works.
That last point is important. Let’s pretend the characters that the display has are “a”, “b”, “c”, and “d”; it is currently displaying “c”; and it needs to get to “b”. Instead of flipping from “c” immediately to “a”, I want it to flip through ”d”, loop around to “a”, before finally landing on “b”.
At first blush, I thought that I would create two elements—one for the top and one for the bottom—for each character or word that needs to get displayed. I quickly discarded that idea for two reasons:
- I dislike having a bunch of elements in the document that aren’t getting displayed
- Managing and keeping track of the stacking on the top and bottom flaps is going to be challenging
So onto the next idea: The top and bottom flaps stay static and the transition is handled entirely by a single middle "flap" and we cycle out the numbers that are displayed instead.
How it works
Whether or not this was the correct way to do things isn't relevant here, I just wanted to see if it could be done: I created a SplitFlap class that holds a single character display, and everything about that single character can be adjusted through the instance of the class that gets created.
We'll start by going over what each method in the class does before we go into each function in a line-by-line explanation.
SplitFlap
.constructor(options)
: Takes one argument that is the text options it can display. Sets up the class and because I wanted this to be a low-touch plug-and-play solution it also creates and sets up the elements that will make up the HTML side as well..setDisplay()
: Syncs the displayed text with the current and next characters to be displayed on the flaps..getValue()
: Returns the text value of the currently displayed value.nextValue()
: Increments the displayed text by one element in the array of options- async
.flip()
: This is where things start to get a little bit complicated. This method returns a promise that gets resolved once the flip animation it starts, ends. - async
.flipTo(value)
: This one repeatedly calls.flip()
until the requested value is displayed .appendTo(parentElement)
: I'm not particularly proud of this one. It appends the elements created in the constructor to the passed inparentElement
.
Starting from the top
Setting the private properties
First, we set a number of private properties. Because of the way JavaScript (currently) works private properties have to be pre-initialized to some value
#options = [];
#currentValue = 0;
#display = null;
#dynamic = null;
#flaps = {};
They'll make more sense later.
constructor(options)
First things first, we check to see if the passed in options
is an array, and throw an error if it isn't. If it is an array we set the private #options
property to the options
that are passed in.
if (typeof options != typeof []) {
throw new TypeError("Variable 'options' is not an array");
}
this.#options = options;
Next up: Creating the elements that will make up this single character display. Because I don't know of a better way to do this, it gets a little bit verbose, so bear with me here.
This first group of lines here sets up the elements into local variables that are going to be the flaps. We will be using most of these later to run animations and update values. container
is what we will be using to hold the character and what we will eventually append to whatever it needs to be. The flap_flap
element and it's flap_flap_*
children is what actually does the wiping animation before resetting and updating. top_flap
and bottom_flap
don't ever move, but they do get value updates.
let container = document.createElement('div');
let top_flap = document.createElement('div');
let bottom_flap = document.createElement('div');
let flap_flap = document.createElement('div');
let flap_flap_front = document.createElement('div');
let flap_flap_back = document.createElement('div');
Now we just take those elements and make them into the tree shape that we need them to be. flap_flap
gets flap_flap_front
and flap_flap_back
appended to it because the flap has two sides. The front side is the current value and as it flips, the back side holds the next value. flap_top
and flap_bottom
are static elements that only fade into mimic getting spun into place like the drum.
flap_flap.append(flap_flap_front, flap_flap_back);
container.append(top_flap, bottom_flap, flap_flap);
And now for the fun bit: we set up the classes on all of our elements. As much as I wanted this to be a low-touch solution, the CSS side of things is still pretty heavily dependent on the JavaScript being exactly right to display correctly.
container.classList.add('dial');
top_flap.classList.add('flap', 'top');
bottom_flap.classList.add('flap', 'bottom');
flap_flap.classList.add('dynamic');
flap_flap_front.classList.add('flap', 'front', 'top');
flap_flap_back.classList.add('flap', 'back', 'bottom');
Almost done with the constructor now. Here, we set up the private properties to hold our variables. These will be important for our animations and character updates later.
this.#dynamic = flap_flap;
this.#flaps.top = top_flap;
this.#flaps.bottom = bottom_flap;
this.#flaps.dynamic = {};
this.#flaps.dynamic.front = flap_flap_front;
this.#flaps.dynamic.back = flap_flap_back;
this.#display = container;
And finally we set up the next and displayed values with
this.setDisplay();
setDisplay()
Performance here isn't a big deal, so we are doing things the long way. I think it makes things a hair more readable. First we get our private properties and throw them into variables. The main goal here is to decrease line length and therefore make everything else more readable.
let index = this.#currentValue;
let length = this.#options.length;
Next, we get the current and next values from the array of options, wrapping over as needed. The current value is what is displayed, the next value is hidden behind the flap that is currently in the up position.
let currentValue = this.#options[index % length];
let nextValue = this.#options[(index + 1) % length];
Now we need to place the values on the correct faces. With the flap in the "up" position, the current value gets displayed on the bottom flap and the front face of the moving flap, and the next value gets displayed on the top flap, and the back side of the moving flap--both of which are hidden because the flapping isn't happening when this method is running.
this.#flaps.top.innerHTML = nextValue;
this.#flaps.bottom.innerHTML = currentValue;
this.#flaps.dynamic.front.innerHTML = currentValue;
this.#flaps.dynamic.back.innerHTML = nextValue;
getValue()
This returns the text representation of the currently displayed value. It's three lines that do pretty much what they say they do, starting with my usually over-verbose local variable initialization.
let length = this.#options.length;
let index = this.#currentValue;
return this.#options[index % length];
nextValue()
Another short method. Because setDisplay()
handles the wrap-around part with a modulus, we aren't going to worry about doing that here and just naïvely increment the #currentValue
property
this.#currentValue++;
this.setDisplay();
async flip()
Now that we have all of that administrivia out of the way, flip()
is what actually drives the whole show. All it does is trigger the animation to flip by adding a class to the #display
property, waits for it to end, and then returns the current value for use later. Making this asynchronous allows us to use promises to reduce browser resource utilization.
let returnValue = new Promise((resolve, _reject) => {
this.#display.classList.add('flipping');
this.#dynamic.onanimationend = () => {
this.nextValue();
this.#display.classList.remove('flipping');
setTimeout(() => resolve(this.getValue()), 0);
};
});
return returnValue;
On the first line, we set up a Promise and assign it to returnValue
. Promises take an arrow function that it runs using JavaScript magic to decide when to resolve. resolve
is a function that you can call to say it completed successfully and reject
is what you can call to indicate there was an error in the Promise when it was run. Because we don't use the reject
variable, we prepend an _
to it to indicate to VS Code that it isn't used.] The arrow function passed into a Promise gets run immediately after the current function stack is finished. If you are curious about the internals of how that works, this excellent video on the Event Loop in JavaScript should get you caught up: https://youtu.be/8aGhZQkoFbQ.
Within the function we supply to the Promise, we first append the class flipping
to the #display
element that will start the animation once returnValue
gets returned to whatever is waiting.
Then, we attach an animationend
event to the #dynamic
property to first: increment the displayed values, remove the flipping
class to reset the position, and then return the currently displayed value. Due to how the event loop works, we need to return the value the next time the Event Loop gets to running Javascript, so we wrap it in a setTimeout()
function with a value of 0.
async flipTo(value)
This one is pretty easy. It resolves once the display has flipped to the value that was passed in.
let returnValue = new Promise(async (resolve, _reject) => {
let currentValue = this.getValue();
while (currentValue != value) {
currentValue = await this.flip();
}
resolve(this.getValue());
});
return returnValue;
We start off a lot like we did before, by setting returnValue
to a Promise with the arrow function.
Then, we just get the current value, and if it isn't equal to the value we want to display, we flip through the values until it does match. The slightly (un)intended downside here is that if you want it to flip until you get a "z", and all you have it set to be able to display is "a", "b", "c", and "d"; it will just flip forever.
Then, once it has landed at the right value, we just return that value. At this point, you may be asking why we don't have a call to setTimeout()
here like we had before. Either way, the reason is that we already have that taken care of in our calls to this.flip()
and waiting any more time wouldn't be productive.
appendTo(parentElement)
This one is just one weird, regrettable line:
parentElement.append(this.#display);
We take the parentElement
and append the #display
property that holds the elements we created in the constructor()
to it.
How do I use it?
This code isn't provided as complete, ready-to-use .js and .css files. You'll either need to assemble it from the code in this page, or pull the class as-is from the script.js and style.css linked from the html page. I do encourage you to take this code as yours to experiment with and improve.
With that in mind, as it is written here to create a single character split flap display that is able to display numbers, all you need to do is:
let char = new SplitFlap(['0', '1', '2', '3', '4', '5', '6', '7', '8', '9']);
Then, let's pretend your application counts from 0 to 9, once per second before looping back around. We just need to create an interval to runs once per second and call the flip()
method on our char
variable. Like so:
let interval = setInterval(async () => char.flip(), 1000);
If you don't know why we are capturing the return value of interval
, it's so that if we need to stop it at some point in time we can do that with clearInterval(interval)
.
Another application for our simple display is to set it to a random value between 0 and 9 every second. Which can be accomplished with:
let interval2 = setInterval(asyc () => {
let randomValue = (Math.random() * 10).toFixed(0);
char.flipTo(randomValue);
})