updated
An animated split-flap display component, aka Solari board, like the ones you’d see in old train stations and airports, bringing back some nostalgic memories. This component is inspired by a similar component I made back in 2012, with many improvements. Instead of faking the physical spool like many implementations do, I decided to stay as close as possible to the physical split-flap displays: the flaps really do rotate along the drum (try checking the Rotate display option). Even tough there may be shortcomings to this approach, I took it as a chance to practice. You can install the open-source package and start adding beautiful split-flap displays to your site or application.
The name comes from Solari di Udine, the Italian manufacturer whose displays ended up in so many European stations and airports that the company name became the word for the thing, the way it did for the biro and the thermos. If you have a memory of a departure board clattering its way through the alphabet while you worked out whether your train was late, it was very probably one of theirs.
The mechanism is worth understanding before the code, because it is what the component models. Each character position is a drum carrying a stack of hinged flaps, each printed with half of one character on its front and half of the next on its back. To change what is shown, the drum turns and the flaps fall past the split under their own weight until the requested one is at the front. That is where the clatter comes from, and it is also why the boards always cycled forwards through the whole set rather than taking the short way round: the drum only turns one way.
It also explains the constraint the digital version inherits. A drum only shows what is physically on it, so a position that carried digits could not suddenly display a letter, and boards were built with different drums in different places: letters where the destinations went, digits where the times did. That is exactly what the characters prop is, and why it takes an array as well as a string. Pass one string and every slot gets the same set. Pass an array and each slot gets its own drum.
Open the repo in Github (and drop a star if you like it!), view quick-start to get started
npm install @daformat/react-split-flap-displayA traditional split-flap clock, cycling through digits as time passes, a timeless design if you ask me. For this one I’m simply passing the digits for each slot and the : character.
A split-flap display that cycles through the alphabet and display rolling messages. For this one, we flip through the entire spool and trigger a new message after the current message is finished displaying.
Below is a minimal example to reproduce the examples above, view the full tsx and scss on github
Note: you will likely want to set perspective: 550px; (or any other value) andtransform-style: preserve-3d; on the root.
import { useState } from "react";
import { SplitFlapDisplay } from "@daformat/react-split-flap-display";
import styles from "./styles.module.css";
const CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ ";
const WORDS = ["HELLO", "WORLD", "REACT", "FLIP"];
export const Demo = () => {
const [word, setWord] = useState<string>(WORDS[0] ?? "HELLO");
const newMessageTimeoutRef =
useRef<ReturnType<typeof setTimeout>>(null);
const incrementMessage = useCallback(() => {
if (newMessageTimeoutRef.current) {
clearTimeout(newMessageTimeoutRef.current);
newMessageTimeoutRef.current = null
}
setWord(
(word) =>
WORDS[(WORDS.indexOf(word) + 1) % WORDS.length] ?? word
);
}, []);
const handleFullyFlipped = useCallback(() => {
if (newMessageTimeoutRef.current) {
clearTimeout(newMessageTimeoutRef.current);
}
newMessageTimeoutRef.current = setTimeout(incrementMessage, 5000);
}, [incrementMessage]);
return (
<SplitFlapDisplay.Root
value={word}
length={5}
characters={CHARS}
flipDuration={800}
onFullyFlipped={handleFullyFlipped}
className={styles.split_flap_display}
/>
);
};The package exports a single namespace SplitFlapDisplay with four compound components: Root, Slot, Character, and Flap. SplitFlapDisplay.Root is the only one you need 99% of the time, it renders all four nested layers automatically. The other three exist for when you want to swap any layer for your own markup, like in the tailwind tab above.
<SplitFlapDisplay.Root>
<SplitFlapDisplay.Slot>
<SplitFlapDisplay.Character>
<SplitFlapDisplay.Flap position="top" />
<SplitFlapDisplay.Flap position="bottom" />
</SplitFlapDisplay.Character>
{/* ...one Character per character in the set */}
</SplitFlapDisplay.Slot>
{/* ...as many Slot as `length` */}
</SplitFlapDisplay.Root>When you don’t pass a children render-prop to a given level, that level renders the level below automatically. So you can compose only the layers you care about and let the defaults handle the rest.
SplitFlapDisplay.RootThe outermost wrapper. Owns the value, the length, the character set and the flip timing. Renders a <div> and accepts every standard <div> prop (className, style, aria-*, data-*, ref, …) on top of the ones below:
value (string): the current value to display. Every character must belong to the corresponding character set, otherwise the component will throw. If the value is shorter than length, the component will pad the value with spaces, so it’s important you include " " in the character set in that case.length (number): the number of slots to render. Values shorter than length are right-padded with spaces; values longer than length are truncated and the last slot becomes an ellipsis (…).characters (string | string[]): the set of characters each slot can flip through. Pass a single string to share the same set across every slot, or an array of length length to give each slot its own set. Each set must be non-empty and contain no duplicates.onFullyFlipped (() => void, optional): fires exactly once after every slot has finished flipping to the current value. Fires again on the next value change. Handy for chaining transitions or syncing audio.crease (number | string, default 1): visual gap between the top and bottom flaps. A number is interpreted as pixels; a string is passed through verbatim (e.g. "0.5rem"). Exposed to CSS as --split-flap-crease.flipDuration (number | string, default 800): duration of the flip animation. A number is interpreted as milliseconds; a string is passed through verbatim (e.g. "1s"). Exposed to CSS as --split-flap-flip-duration.flipTimingFunction (string, default cubic-bezier(.215, .61, .355, 1)): CSS timing function applied to the flip animation. Exposed to CSS as --split-flap-timing-function.children (render-prop, optional): take over slot rendering. Receives (index, characters, currentCharacter, onFullyFlipped) => ReactNode and is called once per slot. Capture currentCharacter from this closure if you need to forward it deeper, it isn’t re-emitted by Slot.children.ref (Ref<HTMLDivElement>, optional): forwarded to the root <div>.SplitFlapDisplay.SlotA single slot in the display: one <span data-split-flap-slot=""> containing every possible character in the slot’s character set, only one of which is current at a time. Forwards every standard <span> prop:
index (number): the slot’s position in the display. Used as the slot’s identity by the onFullyFlipped bookkeeping in Root.characters (string): the character set this slot can flip through. Must be non-empty, no duplicates, and must contain currentCharacter.currentCharacter (string): the character this slot should currently be showing. Changing this triggers the flip animation through every character in between.onFullyFlipped ((character: string, index: number) => void, optional): called after this slot has settled on currentCharacter. When you compose under Root, just pass through the onFullyFlipped you receive from Root’s render-prop.children ((character: string, index: number) => ReactNode, optional): take over character rendering. Called once per character in the set.ref (Ref<HTMLSpanElement>, optional): forwarded to the slot <span>.SplitFlapDisplay.CharacterOne possible character within a slot: one <span data-split-flap-character="" data-char="X"> containing the two rotating flaps. Every character in the set is rendered; the non-current ones are positioned in 3D space behind and ahead of the current one. Forwards every standard <span> prop:
index (number): the character’s position within the slot’s character set.character (string): the character this Character represents.currentCharacter (string): the character the slot is currently showing. The active Character has its inert attribute removed; all others get inert.children ((character: string) => ReactNode, optional): take over flap rendering. Receives the character and is expected to return the two flaps (and any extra layers, like a crease overlay).ref (Ref<HTMLSpanElement>, optional): forwarded to the character <span>.SplitFlapDisplay.FlapA single half of a flap pair: one <span data-split-flap-flap="top|bottom"> that rotates around its top or bottom edge. Forwards every standard <span> prop:
character (string): the character this flap displays.position ("top" | "bottom"): which half of the flap pair this is. The bottom flap is automatically aria-hidden and inert: it’s a visual mirror of the top flap.ref (Ref<HTMLSpanElement>, optional): forwarded to the flap <span>.The component does not choose one, which tends to surprise people. It sets a size and inherits everything else from the page it is on, which is deliberate: a departure board rendered in your own typeface looks like your site, and one rendered in a typeface I picked looks like my demo dropped into your site.
What actually matters is not which face you use but whether its characters are the same width. Every slot here is a fixed box, because on the real thing every drum is the same drum, so a proportional face will make the box jump as it flips between a wide character and a narrow one, and the whole illusion goes with it. Reach for a monospaced face and the problem disappears. If you are only ever showing numbers and you would rather keep your body face, font-variant-numeric: tabular-nums buys you the same thing for digits alone.
Beyond that it is mostly a question of how much character you want. The boards these are modelled on were not typographically precious: the lettering was condensed, heavy, and slightly crude, because it had to be legible from the other end of a concourse and printed on a piece of hinged plastic. A grotesque with tight apertures gets you closer than anything designed to be admired at close range.
I wanted an a real split-flap display component with a rolling drum and implemented it with React, css, and JavaScript, the component accepts a range of characters and creates as many flaps as there are characters. While there might be performance implications, I favoured recreating the full barrel effect as an exercise. I hope you enjoyed the demos. Stay tuned.