Feedback & Data
NumberFlip
A split-flap board. When a character changes, its top half folds down and the new one lands underneath. Only the characters that changed move.
Download
- React
- Tailwind CSS v4
- motion
components/ui/number-flip.tsx
"use client";
import { useState } from "react";
import { motion, useReducedMotion } from "motion/react";
type NumberFlipProps = {
/** What to show, like 1284 or "12:05". Letters and digits flip, the rest stays still. */
value: number | string;
/** Replaces the default tile look. Text size comes from the parent. */
className?: string;
};
const HALF = "absolute inset-x-0 h-1/2 overflow-hidden bg-inherit";
const Face = ({ char, top }: { char: string; top?: boolean }) => (
<span className={`absolute inset-x-0 grid h-[200%] place-items-center ${top ? "top-0" : "bottom-0"}`}>{char}</span>
);
function Flap({ char, className }: { char: string; className: string }) {
const reduce = useReducedMotion();
const [flip, setFlip] = useState({ now: char, was: char });
if (flip.now !== char) setFlip({ now: char, was: flip.now });
const was = reduce ? flip.now : flip.was;
return (
<span className={`relative inline-block h-[1.35em] w-[0.82em] overflow-hidden rounded-[0.14em] [perspective:4em] ${className}`}>
<span className={`${HALF} top-0 border-b border-black/40`}>
<Face char={flip.now} top />
</span>
<span className={`${HALF} bottom-0`}>
<Face char={was} />
</span>
{was !== flip.now && (
<>
<motion.span
key={`top-${was}${flip.now}`}
className={`${HALF} top-0 z-10 origin-bottom border-b border-black/40`}
initial={{ rotateX: 0 }}
animate={{ rotateX: -90 }}
transition={{ duration: 0.2, ease: "easeIn" }}
>
<Face char={was} top />
</motion.span>
<motion.span
key={`bottom-${was}${flip.now}`}
className={`${HALF} bottom-0 z-10 origin-top`}
initial={{ rotateX: 90 }}
animate={{ rotateX: 0 }}
transition={{ duration: 0.28, delay: 0.2, ease: [0.2, 1.5, 0.4, 1] }}
>
<Face char={flip.now} />
</motion.span>
</>
)}
</span>
);
}
/* NumberFlip: a split-flap board. When a character changes, its top half folds down and the new one lands underneath. Only the characters that changed move. */
export function NumberFlip({ value, className = "bg-[#1a1530] text-white" }: NumberFlipProps) {
const text = String(value);
return (
<span className="inline-flex">
<span className="sr-only">{text}</span>
<span aria-hidden className="inline-flex items-center gap-[0.07em] font-mono leading-none font-semibold tabular-nums">
{[...text].map((char, i) =>
/[\p{L}\p{N}]/u.test(char) ? <Flap key={text.length - i} char={char} className={className} /> : <span key={text.length - i}>{char}</span>,
)}
</span>
</span>
);
}
1. Install the dependency.
npm i motion2. Paste the Code tab into
components/ui/number-flip.tsx. The styles are Tailwind CSS v4 classes.3. Use it. This is the preview above:
app/number-flip-demo.tsx
"use client"; import { useEffect, useState } from "react"; import { NumberFlip } from "@/components/ui/number-flip"; export default function NumberFlipExample() { const [count, setCount] = useState(1284); useEffect(() => { const id = setInterval(() => setCount((c) => c + 7), 1800); return () => clearInterval(id); }, []); return ( <div className="grid h-full place-items-center bg-[#0b0a14] p-6"> <div className="grid justify-items-center gap-4"> <p className="text-sm text-white/50">Live visitors</p> <div className="text-4xl"> <NumberFlip value={count.toLocaleString("en-US")} /> </div> <button type="button" onClick={() => setCount((c) => c + 1)} className="rounded-xl border border-white/10 bg-white/5 px-3.5 py-2 text-sm text-white/80 outline-none hover:bg-white/10 focus-visible:ring-2 focus-visible:ring-white/70" > Add one </button> </div> </div> ); }
Props
| Name | Type | Default | What it does |
|---|---|---|---|
| value | number | string | — | What to show, like 1284 or "12:05". Letters and digits flip, the rest stays still. |
| className | string | dark violet tile | Replaces the tile look. Text size comes from the parent. |