All components
Split Flap Board
textAceternity UI component.
responsive · 460px
Install
Same command in any shadcn project — React (Vite/CRA), Next.js, Remix, Astro, and more:
$
npx shadcn@latest add https://your-domain/r/text-flipping-board.jsonUsage
import { TextFlippingBoard } from "@/registry/aceternity-ui/text-flipping-board";
export default function Demo() {
return <TextFlippingBoard />;
}Component source
"use client";
import React, { useEffect, useRef, useState, useMemo } from "react";
import { motion } from "motion/react";
import { cn } from "@/lib/utils";
const FLAP_CHARS = " ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$()-+&=;:'\"%,./?°";
const BOARD_ROWS = 6;
const BOARD_COLS = 22;
const BASE_COL_DELAY = 30;
const BASE_ROW_DELAY = 20;
const BASE_STEP_MS = 55;
const BASE_FLIP_S = 0.35;
const BASE_TOTAL_S =
((BOARD_COLS - 1) * BASE_COL_DELAY +
(BOARD_ROWS - 1) * BASE_ROW_DELAY +
8 * BASE_STEP_MS) /
1000;
type AccentColor = {
top: string;
bottom: string;
text: string;
};
const ACCENT_COLORS: AccentColor[] = [
{ top: "bg-red-600", bottom: "bg-red-700", text: "text-white" },
{ top: "bg-orange-500", bottom: "bg-orange-600", text: "text-white" },
{ top: "bg-yellow-400", bottom: "bg-yellow-500", text: "text-neutral-900" },
{ top: "bg-green-600", bottom: "bg-green-700", text: "text-white" },
{ top: "bg-blue-600", bottom: "bg-blue-700", text: "text-white" },
{ top: "bg-violet-600", bottom: "bg-violet-700", text: "text-white" },
{ top: "bg-white", bottom: "bg-neutral-100", text: "text-neutral-900" },
];
const CELL_TEXT_STYLE: React.CSSProperties = {
fontSize: "clamp(6px, 2vw, 22px)",
lineHeight: 1,
};
// ── Individual Split-Flap Character ───────────────────────────────────
const FlapCell = React.memo(function FlapCell({
target,
delay,
stepMs,
flipDuration,
}: {
target: string;
delay: number;
stepMs: number;
flipDuration: number;
}) {
const [current, setCurrent] = useState(" ");
const [prev, setPrev] = useState(" ");
const [flipId, setFlipId] = useState(0);
const [accent, setAccent] = useState<AccentColor | null>(null);
const [prevAccent, setPrevAccent] = useState<AccentColor | null>(null);
const curRef = useRef(" ");
const tgtRef = useRef<string | null>(null);
const accentRef = useRef<AccentColor | null>(null);
const startTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const stepTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
if (startTimer.current) clearTimeout(startTimer.current);
if (stepTimer.current) clearTimeout(stepTimer.current);
startTimer.current = null;
stepTimer.current = null;
const normalized = FLAP_CHARS.includes(target.toUpperCase())
? target.toUpperCase()
: " ";
if (normalized === tgtRef.current) return;
tgtRef.current = normalized;
if (normalized === " " && curRef.current === " ") return;
const scrambleCount =
normalized === " "
? 8 + Math.floor(Math.random() * 8)
: 25 + Math.floor(Math.random() * 15);
const runStep = (i: number) => {
const isLast = i === scrambleCount;
const ch = isLast
? normalized
: FLAP_CHARS[1 + Math.floor(Math.random() * (FLAP_CHARS.length - 1))];
const newAccent = isLast
? null
: Math.random() < 0.2
? ACCENT_COLORS[Math.floor(Math.random() * ACCENT_COLORS.length)]
: null;
setPrev(curRef.current);
setPrevAccent(accentRef.current);
curRef.current = ch;
accentRef.current = newAccent;
setCurrent(ch);
setAccent(newAccent);
setFlipId((n) => n + 1);
if (!isLast) {
stepTimer.current = setTimeout(() => runStep(i + 1), stepMs);
}
};
startTimer.current = setTimeout(() => runStep(1), delay);
return () => {
if (startTimer.current) clearTimeout(startTimer.current);
if (stepTimer.current) clearTimeout(stepTimer.current);
startTimer.current = null;
stepTimer.current = null;
tgtRef.current = null;
};
}, [target, delay, stepMs]);
const show = current === " " ? "\u00A0" : current;
const showPrev = prev === " " ? "\u00A0" : prev;
const textCx =
"absolute inset-x-0 flex select-none items-center justify-center font-mono font-bold tracking-wide";
const topBg = accent?.top ?? "bg-neutral-200/80 dark:bg-neutral-900";
const bottomBg = accent?.bottom ?? "bg-neutral-200/80 dark:bg-neutral-900";
const textColor = accent?.text ?? "text-neutral-800 dark:text-white";
const flapTopBg = prevAccent?.top ?? "bg-neutral-100 dark:bg-neutral-800";
const flapTextColor = prevAccent?.text ?? "text-neutral-800 dark:text-white";
const bottomDelay = flipDuration * 0.5;
return (
<div className="flex aspect-3/6 flex-col overflow-hidden rounded-[2px] border border-neutral-300 md:rounded-[3px] md:border-2 dark:border-black">
{/* Flap content area */}
<div className="relative flex-1 perspective-dramatic transform-3d">
<div className="absolute inset-0 z-40 hidden flex-row items-center justify-center md:flex">
<div className="h-1/2 w-px rounded-tr-sm rounded-br-sm bg-neutral-300 dark:bg-black" />
<div className="flex h-px flex-1 bg-neutral-300 dark:bg-black" />
<div className="h-1/2 w-px rounded-tl-sm rounded-bl-sm bg-neutral-300 dark:bg-black" />
</div>
{/* Static top – new character top half */}
<div
className={cn(
"absolute inset-x-0 top-0 h-[calc(50%-0.5px)] overflow-hidden rounded-t-[3px]",
topBg,
)}
>
<div
className={cn(textCx, textColor, "top-0 h-[200%]")}
style={CELL_TEXT_STYLE}
>
{show}
</div>
</div>
{/* Static bottom – new character bottom half */}
<div
className={cn(
"absolute inset-x-0 bottom-0 h-[calc(50%-0.5px)] overflow-hidden rounded-b-[3px]",
bottomBg,
)}
>
<div
className={cn(textCx, textColor, "bottom-0 h-[200%]")}
style={CELL_TEXT_STYLE}
>
{show}
</div>
{flipId > 0 && (
<motion.div
key={`s${flipId}`}
className="pointer-events-none absolute inset-0 bg-[linear-gradient(to_bottom,rgba(255,255,255,0.8),transparent_60%)] dark:bg-[linear-gradient(to_bottom,rgba(0,0,0,0.8),transparent_60%)]"
initial={{ opacity: 0.5 }}
animate={{ opacity: 0 }}
transition={{ duration: flipDuration * 1.3, ease: "easeOut" }}
/>
)}
</div>
{/* Flipping top flap – old character top half, drops down */}
{flipId > 0 && (
<motion.div
key={flipId}
className={cn(
"absolute inset-x-0 top-0 z-10 h-[calc(50%-0.5px)] origin-bottom overflow-hidden rounded-t-[3px] backface-hidden transform-3d",
flapTopBg,
)}
initial={{ rotateX: 0 }}
animate={{ rotateX: -100 }}
transition={{
duration: flipDuration,
ease: [0.55, 0.055, 0.675, 0.19],
}}
>
<div
className={cn(textCx, flapTextColor, "top-0 h-[200%]")}
style={CELL_TEXT_STYLE}
>
{showPrev}
</div>
<motion.div
className="pointer-events-none absolute inset-0 bg-[linear-gradient(to_bottom,rgba(255,255,255,0),rgba(255,255,255,1))] dark:bg-[linear-gradient(to_bottom,rgba(0,0,0,0),rgba(0,0,0,1))]"
initial={{ opacity: 0 }}
animate={{ opacity: 0.6 }}
transition={{ duration: flipDuration }}
/>
</motion.div>
)}
{/* Flipping bottom flap – new character bottom half, rises up */}
{flipId > 0 && (
<motion.div
key={`b${flipId}`}
className={cn(
"absolute inset-x-0 bottom-0 z-10 h-[calc(50%-0.5px)] origin-top overflow-hidden rounded-b-[3px] backface-hidden transform-3d",
bottomBg,
)}
initial={{ rotateX: 90 }}
animate={{ rotateX: 0 }}
transition={{
duration: flipDuration * 0.85,
delay: bottomDelay,
ease: [0.33, 1.55, 0.64, 1],
}}
>
<div
className={cn(textCx, textColor, "bottom-0 h-[200%]")}
style={CELL_TEXT_STYLE}
>
{show}
</div>
<motion.div
className="pointer-events-none absolute inset-0 bg-[linear-gradient(to_top,rgba(255,255,255,0),rgba(255,255,255,0.6))] dark:bg-[linear-gradient(to_top,rgba(0,0,0,0),rgba(0,0,0,0.6))]"
initial={{ opacity: 0.4 }}
animate={{ opacity: 0 }}
transition={{
duration: flipDuration * 0.85,
delay: bottomDelay,
}}
/>
</motion.div>
)}
{/* Split line */}
<div className="pointer-events-none absolute inset-x-0 top-1/2 z-20 h-px -translate-y-[0.5px] bg-neutral-400/50 dark:bg-black/50" />
</div>
{/* Bottom stripes – decorative, outside the flap area */}
<div className="h-2 w-full bg-[repeating-linear-gradient(to_bottom,currentColor_0,currentColor_1px,transparent_1px,transparent_0.15rem)] mask-t-from-50% text-neutral-400 opacity-20 md:h-4 md:bg-[repeating-linear-gradient(to_bottom,currentColor_0,currentColor_1px,transparent_1px,transparent_0.2rem)] dark:text-black dark:opacity-100" />
</div>
);
},
(prevProps, nextProps) =>
prevProps.target === nextProps.target &&
prevProps.delay === nextProps.delay &&
prevProps.stepMs === nextProps.stepMs &&
prevProps.flipDuration === nextProps.flipDuration,
);
// ── Color Tile ────────────────────────────────────────────────────────
const COLOR_MAP: Record<string, string> = {
"{R}": "#D32F2F",
"{O}": "#F57C00",
"{Y}": "#FBC02D",
"{G}": "#43A047",
"{B}": "#1E88E5",
"{V}": "#8E24AA",
"{W}": "#FAFAFA",
};
const ColorCell = React.memo(function ColorCell({ color }: { color: string }) {
return (
<div
className="aspect-3/5 rounded-[3px] border-2 border-neutral-300 dark:border-black"
style={{ backgroundColor: color }}
/>
);
});
// ── Row Parser ────────────────────────────────────────────────────────
type ParsedCell =
| { type: "char"; value: string }
| { type: "color"; hex: string };
function parseRow(row: string): ParsedCell[] {
const cells: ParsedCell[] = [];
let i = 0;
while (i < row.length) {
if (row[i] === "{" && i + 2 < row.length && row[i + 2] === "}") {
const code = row.substring(i, i + 3);
if (COLOR_MAP[code]) {
cells.push({ type: "color", hex: COLOR_MAP[code] });
i += 3;
continue;
}
}
cells.push({ type: "char", value: row[i] });
i++;
}
return cells;
}
// ── Word Wrap ─────────────────────────────────────────────────────────
function wrapParagraph(paragraph: string, maxCols: number): string[] {
const lines: string[] = [];
const words = paragraph.split(/[ \t]+/).filter(Boolean);
let currentLine = "";
for (const word of words) {
if (word.length > maxCols) {
if (currentLine) {
lines.push(currentLine);
currentLine = "";
}
lines.push(word.slice(0, maxCols));
continue;
}
if (!currentLine) {
currentLine = word;
} else if (currentLine.length + 1 + word.length <= maxCols) {
currentLine += " " + word;
} else {
lines.push(currentLine);
currentLine = word;
}
}
if (currentLine) lines.push(currentLine);
return lines;
}
function wrapText(input: string, maxCols: number): string[] {
return input
.split("\n")
.flatMap((paragraph) =>
paragraph.trim() === "" ? [""] : wrapParagraph(paragraph, maxCols),
);
}
// ── Main TextFlippingBoard Component ──────────────────────────────────
export interface TextFlippingBoardProps {
rows?: string[];
text?: string;
className?: string;
/** Total animation duration in seconds. Defaults to ~1.2s. */
duration?: number;
}
export function TextFlippingBoard({
rows,
text,
className,
duration = BASE_TOTAL_S,
}: TextFlippingBoardProps) {
const scale = duration / BASE_TOTAL_S;
const colDelay = BASE_COL_DELAY * scale;
const rowDelay = BASE_ROW_DELAY * scale;
const stepMs = BASE_STEP_MS * scale;
const flipDur = Math.min(0.6, Math.max(0.15, BASE_FLIP_S * scale));
const board = useMemo(() => {
const grid: ParsedCell[][] = Array.from({ length: BOARD_ROWS }, () =>
Array.from({ length: BOARD_COLS }, () => ({
type: "char" as const,
value: " ",
})),
);
if (text) {
const lines = wrapText(text, BOARD_COLS).slice(0, BOARD_ROWS);
const startRow = Math.max(0, Math.floor((BOARD_ROWS - lines.length) / 2));
lines.forEach((line, i) => {
const row = startRow + i;
if (row >= BOARD_ROWS) return;
const parsed = parseRow(line);
const startCol = Math.max(
0,
Math.floor((BOARD_COLS - parsed.length) / 2),
);
parsed.forEach((cell, c) => {
if (startCol + c < BOARD_COLS) {
grid[row][startCol + c] = cell;
}
});
});
} else if (rows) {
rows.forEach((row, r) => {
if (r >= BOARD_ROWS) return;
const parsed = parseRow(row);
parsed.forEach((cell, c) => {
if (c < BOARD_COLS) {
grid[r][c] = cell;
}
});
});
}
return grid;
}, [rows, text]);
return (
<div
className={cn(
"relative mx-auto w-full max-w-3xl rounded-xl bg-neutral-100 p-2 shadow-xl md:rounded-2xl md:p-4 dark:bg-neutral-900 dark:shadow-[0_20px_70px_-15px_rgba(0,0,0,0.6)]",
className,
)}
>
<div
className="grid gap-px md:gap-[3px]"
style={{ gridTemplateColumns: `repeat(${BOARD_COLS}, 1fr)` }}
>
{board.map((row, r) =>
row.map((cell, c) =>
cell.type === "color" ? (
<ColorCell key={`${r}-${c}`} color={cell.hex} />
) : (
<FlapCell
key={`${r}-${c}`}
target={cell.value}
delay={c * colDelay + r * rowDelay}
stepMs={stepMs}
flipDuration={flipDur}
/>
),
),
)}
</div>
</div>
);
}Dependencies
motion
Source: Aceternity UI