"use client";
import { useEffect, useRef } from "react";
import { useAnimationLoop } from "@/hooks/use-animation-loop";
import { cn } from "@/lib/utils";
export interface SplitFlapProps {
text?: string;
charset?: "alphanumeric" | "letters" | "digits" | "departures";
flapDuration?: number;
stagger?: number;
settleBounce?: number;
cardGap?: number;
seamWeight?: number;
specular?: number;
perspective?: number;
cardColor?: string;
glyphColor?: string;
paused?: boolean;
reducedMotion?: boolean;
className?: string;
}
const CHARSETS: Record<string, string> = {
alphanumeric: " ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789",
letters: " ABCDEFGHIJKLMNOPQRSTUVWXYZ",
digits: " 0123456789",
departures: " ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789:.-/",
};
interface Cell {
root: HTMLDivElement | null;
topGlyph: HTMLSpanElement | null;
bottomGlyph: HTMLSpanElement | null;
leafA: HTMLDivElement | null;
leafAGlyph: HTMLSpanElement | null;
leafB: HTMLDivElement | null;
leafBGlyph: HTMLSpanElement | null;
sheenA: HTMLDivElement | null;
sheenB: HTMLDivElement | null;
}
const fall = (t: number) => t * t;
const seat = (t: number, bounce: number) => {
const s = bounce * 1.7;
const u = t - 1;
return 1 + (s + 1) * u * u * u + s * u * u;
};
export default function SplitFlap({
text = "SHADOW",
charset = "alphanumeric",
flapDuration = 90,
stagger = 45,
settleBounce = 0.5,
cardGap = 4,
seamWeight = 1,
specular = 0.55,
perspective = 420,
cardColor = "#17141d",
glyphColor = "#e8e4ee",
paused = false,
reducedMotion = false,
className,
}: SplitFlapProps) {
const containerRef = useRef<HTMLDivElement>(null);
const cells = useRef<Cell[]>([]);
const drawRef = useRef<((dt: number) => void | false) | null>(null);
const alphabet = CHARSETS[charset] ?? CHARSETS.alphanumeric;
const columns = Math.max(1, text.length);
const state = useRef<{ index: number[]; target: number[]; t: number[]; landed: boolean[]; elapsed: number }>({
index: [],
target: [],
t: [],
landed: [],
elapsed: 0,
});
const live = useRef({ flapDuration, stagger, settleBounce, specular, alphabet, glyphColor });
live.current = { flapDuration, stagger, settleBounce, specular, alphabet, glyphColor };
const loop = useAnimationLoop({
target: containerRef,
halted: paused,
onFrame: ({ dt }) => (drawRef.current ? drawRef.current(dt) : false),
});
const setWillChange = (on: boolean) => {
for (const cell of cells.current) {
if (cell?.leafA) cell.leafA.style.willChange = on ? "transform" : "";
if (cell?.leafB) cell.leafB.style.willChange = on ? "transform" : "";
}
};
const writeGlyphs = (i: number) => {
const cell = cells.current[i];
if (!cell) return;
const { alphabet: set } = live.current;
const cur = set[state.current.index[i] % set.length] ?? " ";
const next = set[(state.current.index[i] + 1) % set.length] ?? " ";
if (cell.topGlyph) cell.topGlyph.textContent = next;
if (cell.bottomGlyph) cell.bottomGlyph.textContent = cur;
if (cell.leafAGlyph) cell.leafAGlyph.textContent = cur;
if (cell.leafBGlyph) cell.leafBGlyph.textContent = next;
};
const settleCell = (i: number) => {
const cell = cells.current[i];
if (!cell) return;
const { alphabet: set } = live.current;
const glyph = set[state.current.index[i] % set.length] ?? " ";
if (cell.topGlyph) cell.topGlyph.textContent = glyph;
if (cell.bottomGlyph) cell.bottomGlyph.textContent = glyph;
if (cell.leafAGlyph) cell.leafAGlyph.textContent = glyph;
if (cell.leafBGlyph) cell.leafBGlyph.textContent = glyph;
if (cell.leafA) cell.leafA.style.transform = "rotateX(0deg)";
if (cell.leafB) cell.leafB.style.transform = "rotateX(0deg)";
if (cell.sheenA) cell.sheenA.style.opacity = "0";
if (cell.sheenB) cell.sheenB.style.opacity = "0";
};
useEffect(() => {
const set = alphabet;
const s = state.current;
const upper = text.toUpperCase();
for (let i = 0; i < columns; i++) {
const ch = upper[i] ?? " ";
const found = set.indexOf(ch);
s.target[i] = found >= 0 ? found : 0;
if (s.index[i] === undefined) s.index[i] = 0;
s.t[i] = 0;
s.landed[i] = reducedMotion || s.index[i] === s.target[i];
}
s.index.length = columns;
s.target.length = columns;
s.elapsed = 0;
if (reducedMotion) {
for (let i = 0; i < columns; i++) {
s.index[i] = s.target[i];
s.landed[i] = true;
settleCell(i);
}
setWillChange(false);
return;
}
for (let i = 0; i < columns; i++) {
if (s.landed[i]) settleCell(i);
else writeGlyphs(i);
}
setWillChange(true);
loop.start();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [text, alphabet, columns, reducedMotion]);
useEffect(() => {
drawRef.current = (dt) => {
const s = state.current;
const { flapDuration: ms, stagger: gap, settleBounce: bounce, specular: spec } = live.current;
s.elapsed += dt * 1000;
let moving = false;
for (let i = 0; i < s.index.length; i++) {
const cell = cells.current[i];
if (!cell) continue;
if (s.landed[i]) continue;
if (s.elapsed < i * gap) {
moving = true;
continue;
}
moving = true;
s.t[i] += (dt * 1000) / Math.max(16, ms);
while (s.t[i] >= 1) {
s.t[i] -= 1;
s.index[i] = (s.index[i] + 1) % live.current.alphabet.length;
if (s.index[i] === s.target[i]) {
s.landed[i] = true;
s.t[i] = 0;
settleCell(i);
break;
}
writeGlyphs(i);
}
if (s.landed[i]) continue;
const t = s.t[i];
let a = 0;
let b = 0;
if (t < 0.5) {
a = -90 * fall(t / 0.5);
b = 90;
} else {
a = -90;
b = 90 * (1 - seat((t - 0.5) / 0.5, bounce));
}
if (cell.leafA) cell.leafA.style.transform = `rotateX(${a}deg)`;
if (cell.leafB) cell.leafB.style.transform = `rotateX(${b}deg)`;
if (cell.sheenA) cell.sheenA.style.opacity = `${Math.abs(Math.sin((a * Math.PI) / 180)) * spec}`;
if (cell.sheenB) cell.sheenB.style.opacity = `${Math.abs(Math.sin((b * Math.PI) / 180)) * spec}`;
}
if (!moving) {
setWillChange(false);
return false;
}
};
return () => {
drawRef.current = null;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const topFace = `linear-gradient(180deg, rgba(255,255,255,0.10), rgba(255,255,255,0)), ${cardColor}`;
const bottomFace = `linear-gradient(180deg, rgba(0,0,0,0.35), rgba(0,0,0,0)), ${cardColor}`;
const seam = `${seamWeight}px solid rgba(0,0,0,0.9)`;
const catchLight = "1px solid rgba(255,255,255,0.09)";
const sheen = "linear-gradient(180deg, rgba(255,255,255,0.55), rgba(255,255,255,0))";
return (
<div
ref={containerRef}
className={cn("inline-flex", className)}
style={{ gap: cardGap, color: glyphColor }}
role="img"
aria-label={text}
>
{Array.from({ length: columns }, (_, i) => (
<div
key={i}
ref={(el) => {
cells.current[i] = { ...(cells.current[i] ?? ({} as Cell)), root: el };
}}
className="relative h-[1.4em] w-[0.95em] shrink-0 rounded-[0.08em]"
style={{ perspective: `${perspective}px` }}
>
<div
className="absolute inset-x-0 top-0 h-1/2 overflow-hidden rounded-t-[0.08em]"
style={{ background: topFace, borderBottom: seam }}
>
<span
ref={(el) => {
cells.current[i] = { ...(cells.current[i] ?? ({} as Cell)), topGlyph: el };
}}
className="absolute inset-x-0 top-0 flex h-[200%] items-center justify-center"
/>
</div>
<div
className="absolute inset-x-0 bottom-0 h-1/2 overflow-hidden rounded-b-[0.08em]"
style={{ background: bottomFace, borderTop: catchLight }}
>
<span
ref={(el) => {
cells.current[i] = { ...(cells.current[i] ?? ({} as Cell)), bottomGlyph: el };
}}
className="absolute inset-x-0 -top-full flex h-[200%] items-center justify-center"
/>
</div>
<div
ref={(el) => {
cells.current[i] = { ...(cells.current[i] ?? ({} as Cell)), leafA: el };
}}
className="absolute inset-x-0 top-0 h-1/2 origin-bottom overflow-hidden rounded-t-[0.08em]"
style={{ background: topFace, borderBottom: seam }}
>
<span
ref={(el) => {
cells.current[i] = { ...(cells.current[i] ?? ({} as Cell)), leafAGlyph: el };
}}
className="absolute inset-x-0 top-0 flex h-[200%] items-center justify-center"
/>
<div
ref={(el) => {
cells.current[i] = { ...(cells.current[i] ?? ({} as Cell)), sheenA: el };
}}
className="pointer-events-none absolute inset-0"
style={{ background: sheen, opacity: 0 }}
/>
</div>
<div
ref={(el) => {
cells.current[i] = { ...(cells.current[i] ?? ({} as Cell)), leafB: el };
}}
className="absolute inset-x-0 bottom-0 h-1/2 origin-top overflow-hidden rounded-b-[0.08em]"
style={{ background: bottomFace, borderTop: catchLight }}
>
<span
ref={(el) => {
cells.current[i] = { ...(cells.current[i] ?? ({} as Cell)), leafBGlyph: el };
}}
className="absolute inset-x-0 -top-full flex h-[200%] items-center justify-center"
/>
<div
ref={(el) => {
cells.current[i] = { ...(cells.current[i] ?? ({} as Cell)), sheenB: el };
}}
className="pointer-events-none absolute inset-0"
style={{ background: sheen, opacity: 0 }}
/>
</div>
</div>
))}
</div>
);
}