DocDoeAI / src /components /reactbits /SplitText.tsx
asnannp's picture
deploy: sync backend to Space root (learn-lesson HF cache fix)
65d68f5
Raw History Blame Contribute Delete
4.46 kB
"use client";
import { useEffect, useRef } from "react";
import { gsap } from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
type SplitTextProps = {
text: string;
className?: string;
delay?: number;
duration?: number;
ease?: string;
splitType?: "chars" | "words" | "lines" | "chars,words" | "words,lines" | "chars,words,lines";
from?: gsap.TweenVars;
to?: gsap.TweenVars;
threshold?: number;
rootMargin?: string;
textAlign?: "left" | "center" | "right";
tag?: "p" | "h1" | "h2" | "h3" | "span";
onLetterAnimationComplete?: () => void;
showCallback?: boolean;
};
export default function SplitText({
text = "",
className = "",
delay = 50,
duration = 1.25,
ease = "power3.out",
splitType = "chars",
from = { opacity: 0, y: 40 },
to = { opacity: 1, y: 0 },
threshold = 0.1,
rootMargin = "-100px",
textAlign = "center",
tag = "p",
onLetterAnimationComplete,
showCallback = false,
}: SplitTextProps) {
const ref = useRef<HTMLElement | null>(null);
const animationCompletedRef = useRef(false);
const onCompleteRef = useRef(onLetterAnimationComplete);
useEffect(() => {
onCompleteRef.current = onLetterAnimationComplete;
}, [onLetterAnimationComplete]);
useEffect(() => {
if (typeof window === "undefined" || !ref.current || !text) return;
if (animationCompletedRef.current) return;
gsap.registerPlugin(ScrollTrigger);
const el = ref.current;
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduceMotion) {
animationCompletedRef.current = true;
onCompleteRef.current?.();
return;
}
const startPct = (1 - threshold) * 100;
const marginMatch = /^(-?\d+(?:\.\d+)?)(px|em|rem|%)?$/.exec(rootMargin);
const marginValue = marginMatch ? parseFloat(marginMatch[1]) : 0;
const marginUnit = marginMatch ? marginMatch[2] || "px" : "px";
const sign =
marginValue === 0
? ""
: marginValue < 0
? `-=${Math.abs(marginValue)}${marginUnit}`
: `+=${marginValue}${marginUnit}`;
const start = `top ${startPct}%${sign}`;
// Select targets based on splitType
let targets: Element[] = [];
if (splitType.includes("chars")) {
targets = Array.from(el.querySelectorAll(".split-char"));
} else if (splitType.includes("words")) {
targets = Array.from(el.querySelectorAll(".split-word"));
} else {
targets = [el];
}
// Set initial state
gsap.set(targets, { ...from });
// Animate using scrollTrigger
const anim = gsap.to(targets, {
...to,
duration,
ease,
stagger: delay / 1000,
scrollTrigger: {
trigger: el,
start,
once: true,
fastScrollEnd: true,
anticipatePin: 0.4,
},
onComplete: () => {
animationCompletedRef.current = true;
onCompleteRef.current?.();
},
force3D: true,
});
return () => {
ScrollTrigger.getAll().forEach((trigger) => {
if (trigger.trigger === el) trigger.kill();
});
anim.kill();
};
}, [
delay,
duration,
ease,
from,
rootMargin,
showCallback,
splitType,
text,
threshold,
to,
]);
const Tag = tag;
const words = text.split(" ");
return (
<Tag
ref={(node) => {
ref.current = node;
}}
className={`split-parent ${className}`.trim()}
style={{
textAlign,
overflow: "hidden",
display: "inline-block",
whiteSpace: "normal",
wordWrap: "break-word",
willChange: "transform, opacity",
}}
>
{splitType.includes("chars") ? (
words.map((word, wIdx) => (
<span key={wIdx} className="split-word inline-block" style={{ whiteSpace: "nowrap" }}>
{word.split("").map((char, cIdx) => (
<span key={cIdx} className="split-char inline-block" style={{ display: "inline-block" }}>
{char}
</span>
))}
{wIdx < words.length - 1 ? "\u00A0" : ""}
</span>
))
) : splitType.includes("words") ? (
words.map((word, wIdx) => (
<span key={wIdx} className="split-word inline-block" style={{ display: "inline-block" }}>
{word}
{wIdx < words.length - 1 ? "\u00A0" : ""}
</span>
))
) : (
text
)}
</Tag>
);
}