Back to gallery

Design engineering: a split-flap display component

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.

Install

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-display

Fig. 1: a split-flap clock

A 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.

0120123456789:0123450123456789:0123450123456789

Fig. 2: An alphabetic split-flap display

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.

ABCDEFGHIJKLMNOPQRSTUVWXYZABCDEFGHIJKLMNOPQRSTUVWXYZABCDEFGHIJKLMNOPQRSTUVWXYZABCDEFGHIJKLMNOPQRSTUVWXYZABCDEFGHIJKLMNOPQRSTUVWXYZ

Quick start

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}
    />
  );
};

Component API

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.Root

The 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.Slot

A 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.Character

One 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.Flap

A 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>.

That’s a wrap

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.

Up next

A slider component
-->
<--

Right before

A Number Flow Input component