Split-Flap Display
Airport departure board style letter flipping with 3D hinge animation
animationtextsplit-flapretroticker
Install dependencies
$npm install framer-motionPreview
Source Code
"use client";
import { useEffect, useState, useCallback } from "react";
import { motion, AnimatePresence } from "framer-motion";
interface SplitFlapDisplayProps {
words?: string[];
interval?: number;
charWidth?: string;
className?: string;
}
const CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 ".split("");
function FlapChar({
target,
delay,
charWidth,
}: {
target: string;
delay: number;
charWidth: string;
}) {
const [current, setCurrent] = useState(target);
const [isFlipping, setIsFlipping] = useState(false);
useEffect(() => {
if (current === target) return;
const startIndex = CHARS.indexOf(current.toUpperCase());
const targetIndex = CHARS.indexOf(target.toUpperCase());
if (startIndex === -1 || targetIndex === -1) {
setCurrent(target);
return;
}
let step = 0;
const totalSteps =
targetIndex >= startIndex
? targetIndex - startIndex
: CHARS.length - startIndex + targetIndex;
if (totalSteps === 0) return;
const timeout = setTimeout(() => {
setIsFlipping(true);
const interval = setInterval(() => {
step++;
const idx = (startIndex + step) % CHARS.length;
setCurrent(CHARS[idx]);
if (step >= totalSteps) {
clearInterval(interval);
setIsFlipping(false);
}
}, 60);
}, delay);
return () => clearTimeout(timeout);
}, [target]);
return (
<div
className="relative inline-flex items-center justify-center overflow-hidden rounded-md bg-gray-900 border border-gray-700"
style={{
width: charWidth,
height: "1.6em",
perspective: "200px",
}}
>
{/* Horizontal divider line */}
<div className="absolute inset-x-0 top-1/2 z-20 h-px bg-gray-800/80" />
<AnimatePresence mode="popLayout">
<motion.span
key={current}
initial={isFlipping ? { rotateX: -90, opacity: 0.5 } : false}
animate={{ rotateX: 0, opacity: 1 }}
exit={{ rotateX: 90, opacity: 0.5 }}
transition={{ duration: 0.08, ease: "easeOut" }}
className="block text-center font-mono font-bold text-white"
style={{
transformOrigin: "center center",
backfaceVisibility: "hidden",
}}
>
{current}
</motion.span>
</AnimatePresence>
</div>
);
}
export default function SplitFlapDisplay({
words = ["HELLO"],
interval = 3000,
charWidth = "1.2em",
className = "",
}: SplitFlapDisplayProps) {
const maxLen = Math.max(...words.map((w) => w.length));
const [wordIndex, setWordIndex] = useState(0);
const currentWord = words[wordIndex].toUpperCase().padEnd(maxLen, " ");
useEffect(() => {
if (words.length <= 1) return;
const timer = setInterval(() => {
setWordIndex((prev) => (prev + 1) % words.length);
}, interval);
return () => clearInterval(timer);
}, [words.length, interval]);
return (
<div className={`inline-flex gap-[3px] ${className}`}>
{currentWord.split("").map((char, i) => (
<FlapChar key={i} target={char} delay={i * 80} charWidth={charWidth} />
))}
</div>
);
}