"use client";
import * as React from "react";
import { motion, useInView, useReducedMotion, type Variants } from "motion/react";
import { cn } from "@/lib/utils";
type Tag = "h1" | "h2" | "h3" | "h4" | "p" | "span" | "div";
export interface SplitTextRevealProps {
text: string;
/** Split into words or individual characters. */
by?: "word" | "char";
as?: Tag;
/** Seconds between each piece. */
stagger?: number;
/** Seconds before the first piece starts. */
delay?: number;
/** Seconds each piece takes. */
duration?: number;
/** Starting vertical offset, e.g. "0.6em" or 24. */
offset?: string | number;
/** Starting blur in px (0 to disable). */
blur?: number;
/** Reveal only the first time it enters view. */
once?: boolean;
/** How much of the element must be visible (0–1). */
amount?: number;
/** Scroll container to observe against (defaults to the viewport). */
root?: React.RefObject<Element | null>;
className?: string;
/** Class applied to each word / character span (e.g. a gradient). */
pieceClassName?: string;
}
export function SplitTextReveal({
text,
by = "word",
as = "h2",
stagger,
delay = 0,
duration = 0.6,
offset = "0.5em",
blur = 10,
once = true,
amount = 0.4,
root,
className,
pieceClassName,
}: SplitTextRevealProps) {
const reduce = useReducedMotion();
const ref = React.useRef<HTMLElement>(null);
const inView = useInView(ref, { once, amount, root });
const step = stagger ?? (by === "char" ? 0.025 : 0.07);
const piece: Variants = {
hidden: reduce ? { opacity: 0 } : { opacity: 0, y: offset, filter: `blur(${blur}px)` },
show: (i: number) => ({
opacity: 1,
y: 0,
filter: "blur(0px)",
transition: { delay: delay + i * step, duration: reduce ? 0.2 : duration, ease: [0.22, 1, 0.36, 1] },
}),
};
const words = text.split(/(\s+)/);
let counter = 0;
const Comp = motion[as] as typeof motion.div;
return (
<Comp
ref={ref as React.RefObject<HTMLDivElement>}
initial="hidden"
animate={inView ? "show" : "hidden"}
className={cn(className)}
>
<span className="sr-only">{text}</span>
{words.map((w, wi) => {
if (/^\s+$/.test(w)) return <span key={wi} aria-hidden> </span>;
if (by === "word") {
const i = counter++;
return (
<motion.span key={wi} aria-hidden custom={i} variants={piece} className={cn("inline-block will-change-transform", pieceClassName)}>
{w}
</motion.span>
);
}
// Keep characters of one word together so lines never break mid-word.
return (
<span key={wi} aria-hidden className="inline-block whitespace-nowrap">
{Array.from(w).map((c, ci) => {
const i = counter++;
return (
<motion.span key={ci} custom={i} variants={piece} className={cn("inline-block will-change-transform", pieceClassName)}>
{c}
</motion.span>
);
})}
</span>
);
})}
</Comp>
);
}