Split-Flap Display

Airport departure board style letter flipping with 3D hinge animation

animationtextsplit-flapretroticker
Install dependencies
$npm install framer-motion
Preview

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