An airport departures board: every tile flips through its drum, one character at a time, to land on your text.
Customize
Usage
import { SplitFlap } from "@/components/motionkit/split-flap";
<SplitFlap text="DEPARTURES" className="text-4xl" />Motion
The exact curves this component runs, plotted with Motion's own maths.
Flips
Scrolls into view
Works in the App Router as is. Requires Tailwind CSS v4.
npm install motionSave as components/motionkit/split-flap.tsx.
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { motion, useInView, useReducedMotion } from "motion/react";
const DEFAULT_CHARSET = " ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789.,:-!?'&/";
type SplitFlapProps = {
text: string;
/** Number of tiles. Shorter text is padded with blanks; defaults to the text length. */
length?: number;
/** The characters on each drum, in flip order. Anything else shows as a blank. */
charset?: string;
/** Milliseconds per flip. */
flipSpeed?: number;
/** Milliseconds between each tile starting, left to right. */
stagger?: number;
/** "view" starts when the board scrolls into view; "mount" starts straight away. */
trigger?: "view" | "mount";
className?: string;
};
type Tile = { previous: number; current: number; flips: number };
export function SplitFlap(props: SplitFlapProps) {
const length = Math.max(props.length ?? 0, props.text.length);
// a different tile count is a different board, so start it fresh
return <Board key={length} {...props} length={length} />;
}
function Board({
text,
length,
charset = DEFAULT_CHARSET,
flipSpeed = 70,
stagger = 40,
trigger = "view",
className = "",
}: SplitFlapProps & { length: number }) {
const ref = useRef<HTMLDivElement>(null);
// most of the board, not all of it: a long board can be wider than a phone screen
const inView = useInView(ref, { once: true, amount: 0.6 });
const reduceMotion = useReducedMotion();
const started = trigger === "mount" || inView;
const targets = useMemo(
() =>
Array.from({ length }, (_, i) => {
const index = charset.indexOf((text[i] ?? " ").toUpperCase());
return index === -1 ? 0 : index;
}),
[text, length, charset],
);
const [tiles, setTiles] = useState<Tile[]>(() => Array.from({ length }, () => ({ previous: 0, current: 0, flips: 0 })));
useEffect(() => {
if (!started || reduceMotion) return;
const startedAt = performance.now();
// one clock for the whole board: each tick moves every unfinished tile on by one character,
// always forwards, the way a real drum turns
const timer = window.setInterval(() => {
const elapsed = performance.now() - startedAt;
setTiles((current) => {
let moving = false;
const next = current.map((tile, i) => {
if (tile.current === targets[i] || elapsed < i * stagger) return tile;
moving = true;
return { previous: tile.current, current: (tile.current + 1) % charset.length, flips: tile.flips + 1 };
});
if (!moving) window.clearInterval(timer);
return moving ? next : current;
});
}, flipSpeed);
return () => window.clearInterval(timer);
}, [started, reduceMotion, targets, flipSpeed, stagger, charset.length]);
return (
<div ref={ref} className={`inline-flex gap-[0.08em] font-semibold text-[#F6F1E8] ${className}`}>
<span className="sr-only">{text}</span>
{tiles.map((tile, i) => {
// reduced motion skips the flipping and shows the final board
const shown = reduceMotion ? { previous: targets[i], current: targets[i], flips: 0 } : tile;
return <FlapTile key={i} tile={shown} charset={charset} duration={flipSpeed / 1000} />;
})}
</div>
);
}
function FlapTile({ tile, charset, duration }: { tile: Tile; charset: string; duration: number }) {
const previous = charset[tile.previous];
const current = charset[tile.current];
return (
<span aria-hidden className="relative block h-[1.4em] w-[0.95em] [perspective:400px]">
{/* behind the flaps: the new top half, and the old bottom half until the falling flap covers it */}
<Half char={current} side="top" />
<Half char={previous} side="bottom" />
{/* each flap covers the whole tile and turns about its centre line, which is where the hinge sits */}
{tile.flips > 0 && (
<>
<motion.span
key={`top-${tile.flips}`}
className="absolute inset-0 [backface-visibility:hidden]"
initial={{ rotateX: 0 }}
animate={{ rotateX: -90 }}
transition={{ duration: duration / 2, ease: "easeIn" }}
>
<Half char={previous} side="top" />
</motion.span>
<motion.span
key={`bottom-${tile.flips}`}
className="absolute inset-0 [backface-visibility:hidden]"
initial={{ rotateX: 90 }}
animate={{ rotateX: 0 }}
transition={{ duration: duration / 2, delay: duration / 2, ease: "easeOut" }}
>
<Half char={current} side="bottom" />
</motion.span>
</>
)}
{/* the hinge line across the middle */}
<span className="absolute inset-x-0 top-1/2 h-px -translate-y-px bg-black/70" />
</span>
);
}
function Half({ char, side }: { char: string; side: "top" | "bottom" }) {
const top = side === "top";
return (
<span
className={`absolute inset-x-0 h-1/2 overflow-hidden ${
top ? "top-0 rounded-t-[0.12em] bg-[#1D2727]" : "bottom-0 rounded-b-[0.12em] bg-[#182121]"
}`}
>
{/* the full character, positioned so only this half shows */}
<span className={`absolute inset-x-0 flex h-[200%] items-center justify-center leading-none ${top ? "top-0" : "bottom-0"}`}>
{char}
</span>
</span>
);
}
| Prop | Type | Default | Description |
|---|---|---|---|
| text | string | — | What the board should read. Change it and the tiles flip to the new text. |
| length | number | — | Tile count. Shorter text is padded with blanks; defaults to the text length. |
| charset | string | A–Z, 0–9 and common punctuation | Characters on each drum, in flip order. |
| flipSpeed | number | 70 | Milliseconds per flip. |
| stagger | number | 40 | Milliseconds between each tile starting. |
| trigger | "view" | "mount" | "view" | Start on scroll into view, or straight away. |
Monthly revenue
Number Ticker
Counts up to a value with an eased finish when it scrolls into view.
Small details make the whole thing feel alive.
Word Rise
Words lift out of a blur one after another as the line scrolls into view.
Make every word land.
Kinetic Headline
Headlines that arrive word by word or letter by letter: slide, blur, hinge or rise from behind a mask, from any direction.