Initial commit
This commit is contained in:
286
src/components/text/TextAnimation.tsx
Normal file
286
src/components/text/TextAnimation.tsx
Normal file
@@ -0,0 +1,286 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useEffect, memo } from "react";
|
||||
import { gsap } from "gsap";
|
||||
import { ScrollTrigger } from "gsap/ScrollTrigger";
|
||||
import { SplitText } from "gsap/SplitText";
|
||||
import { cls } from "@/lib/utils";
|
||||
|
||||
gsap.registerPlugin(ScrollTrigger, SplitText);
|
||||
|
||||
interface ScrollTriggerConfig {
|
||||
trigger: HTMLElement;
|
||||
start: string;
|
||||
end: string;
|
||||
markers: boolean;
|
||||
toggleActions: string;
|
||||
}
|
||||
|
||||
type AnimationType = "entrance-slide" | "reveal-blur" | "background-highlight";
|
||||
type VariantType = "trigger" | "words-trigger";
|
||||
|
||||
interface TextAnimationProps {
|
||||
type?: AnimationType;
|
||||
text: string;
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
duration?: number;
|
||||
stagger?: number;
|
||||
start?: string;
|
||||
end?: string;
|
||||
variant?: VariantType;
|
||||
ariaLabel?: string;
|
||||
as?: "div" | "span" | "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "p";
|
||||
gradientColors?: {
|
||||
from: string;
|
||||
to: string;
|
||||
};
|
||||
}
|
||||
|
||||
const ANIMATION_CONFIG = {
|
||||
trigger: {
|
||||
stagger: 0.0075,
|
||||
useDuration: false,
|
||||
duration: 0.6,
|
||||
},
|
||||
"words-trigger": {
|
||||
stagger: 0.03,
|
||||
useDuration: false,
|
||||
duration: 0.6,
|
||||
},
|
||||
} as const;
|
||||
|
||||
const ANIMATION_TYPES = {
|
||||
"entrance-slide": {
|
||||
classPrefix: "slide",
|
||||
fromVars: {
|
||||
opacity: 0,
|
||||
yPercent: 50,
|
||||
},
|
||||
toVars: {
|
||||
opacity: 1,
|
||||
yPercent: 0,
|
||||
ease: "power1",
|
||||
},
|
||||
},
|
||||
"reveal-blur": {
|
||||
classPrefix: "blur",
|
||||
fromVars: {
|
||||
autoAlpha: 0,
|
||||
filter: "blur(10px)",
|
||||
},
|
||||
toVars: {
|
||||
autoAlpha: 1,
|
||||
filter: "blur(0px)",
|
||||
ease: "power1.inOut",
|
||||
},
|
||||
},
|
||||
"background-highlight": {
|
||||
classPrefix: "highlight",
|
||||
fromVars: {
|
||||
opacity: 0.25,
|
||||
},
|
||||
toVars: {
|
||||
opacity: 1,
|
||||
ease: "power2.inOut",
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
const TextAnimation = ({
|
||||
type = "entrance-slide",
|
||||
text,
|
||||
children,
|
||||
className = "",
|
||||
duration = 1,
|
||||
stagger,
|
||||
start = "top 80%",
|
||||
end = "top 20%",
|
||||
variant = "trigger",
|
||||
ariaLabel,
|
||||
as = "div",
|
||||
gradientColors,
|
||||
}: TextAnimationProps) => {
|
||||
const textRef = useRef<HTMLDivElement | HTMLSpanElement>(null);
|
||||
const splitRef = useRef<SplitText | null>(null);
|
||||
const scrollTriggerRef = useRef<ScrollTrigger | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!textRef.current) return;
|
||||
|
||||
const ctx = gsap.context(() => {
|
||||
const isWords = variant === "words-trigger";
|
||||
const animConfig = ANIMATION_TYPES[type];
|
||||
const classPrefix = animConfig.classPrefix;
|
||||
|
||||
splitRef.current = new SplitText(textRef.current!, {
|
||||
type: isWords ? "lines,words" : "lines,words,chars",
|
||||
linesClass: `${classPrefix}-line`,
|
||||
wordsClass: `${classPrefix}-word`,
|
||||
charsClass: isWords ? undefined : `${classPrefix}-char`,
|
||||
});
|
||||
|
||||
const lines = splitRef.current.lines;
|
||||
if (type === "entrance-slide") {
|
||||
gsap.set(lines, {
|
||||
overflow: "hidden",
|
||||
display: as === "span" ? "inline" : undefined,
|
||||
});
|
||||
} else if (as === "span") {
|
||||
gsap.set(lines, {
|
||||
display: "inline",
|
||||
});
|
||||
}
|
||||
|
||||
const words = splitRef.current.words;
|
||||
gsap.set(words, {
|
||||
display: "inline-block",
|
||||
whiteSpace: "nowrap",
|
||||
});
|
||||
|
||||
const animateTarget = isWords
|
||||
? splitRef.current.words
|
||||
: splitRef.current.chars;
|
||||
const config = ANIMATION_CONFIG[variant];
|
||||
const animationDuration = config.useDuration
|
||||
? duration
|
||||
: config.duration!;
|
||||
const animationStagger = stagger ?? config.stagger;
|
||||
|
||||
if (gradientColors) {
|
||||
animateTarget.forEach((element) => {
|
||||
gsap.set(element as HTMLElement, {
|
||||
backgroundImage: `linear-gradient(180deg, ${gradientColors.from} 0%, ${gradientColors.to} 100%)`,
|
||||
WebkitBackgroundClip: "text",
|
||||
backgroundClip: "text",
|
||||
WebkitTextFillColor: "transparent",
|
||||
display: "inline-block",
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const scrollTriggerConfig: ScrollTriggerConfig = {
|
||||
trigger: textRef.current!,
|
||||
start: start,
|
||||
end: end,
|
||||
markers: false,
|
||||
toggleActions: "play none none none",
|
||||
};
|
||||
|
||||
if (type === "reveal-blur") {
|
||||
const tl = gsap.timeline({
|
||||
scrollTrigger: scrollTriggerConfig,
|
||||
});
|
||||
|
||||
const parentElement = textRef.current;
|
||||
const splitInstance = splitRef.current;
|
||||
|
||||
tl.fromTo(
|
||||
animateTarget,
|
||||
animConfig.fromVars,
|
||||
{
|
||||
...animConfig.toVars,
|
||||
duration: animationDuration,
|
||||
stagger: animationStagger,
|
||||
force3D: true,
|
||||
onStart: function () {
|
||||
if (this._targets && this._targets.length > 0) {
|
||||
this._targets.forEach((target: HTMLElement) => {
|
||||
target.style.willChange = "filter, opacity, transform";
|
||||
});
|
||||
}
|
||||
},
|
||||
onComplete: function () {
|
||||
if (this._targets && this._targets.length > 0) {
|
||||
this._targets.forEach((target: HTMLElement) => {
|
||||
target.style.willChange = "auto";
|
||||
});
|
||||
}
|
||||
|
||||
if (parentElement && splitInstance) {
|
||||
gsap.set(parentElement, {
|
||||
opacity: 1,
|
||||
visibility: 'visible',
|
||||
filter: 'blur(0px)',
|
||||
transform: 'none'
|
||||
});
|
||||
|
||||
splitInstance.revert();
|
||||
splitRef.current = null;
|
||||
}
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
scrollTriggerRef.current = tl.scrollTrigger as ScrollTrigger;
|
||||
} else {
|
||||
const parentElement = textRef.current;
|
||||
const splitInstance = splitRef.current;
|
||||
|
||||
gsap.fromTo(
|
||||
animateTarget,
|
||||
animConfig.fromVars,
|
||||
{
|
||||
...animConfig.toVars,
|
||||
duration: animationDuration,
|
||||
stagger: animationStagger,
|
||||
force3D: true,
|
||||
scrollTrigger: scrollTriggerConfig,
|
||||
onComplete: () => {
|
||||
if (parentElement && splitInstance) {
|
||||
gsap.set(parentElement, {
|
||||
opacity: 1,
|
||||
transform: 'none'
|
||||
});
|
||||
|
||||
splitInstance.revert();
|
||||
splitRef.current = null;
|
||||
}
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
}, textRef);
|
||||
|
||||
const currentTextRef = textRef.current;
|
||||
|
||||
return () => {
|
||||
if (
|
||||
type === "reveal-blur" &&
|
||||
scrollTriggerRef.current &&
|
||||
scrollTriggerRef.current.trigger === currentTextRef
|
||||
) {
|
||||
scrollTriggerRef.current.kill();
|
||||
}
|
||||
ctx.revert();
|
||||
if (splitRef.current) {
|
||||
splitRef.current.revert();
|
||||
}
|
||||
};
|
||||
}, [text, type, duration, stagger, start, end, variant, as, gradientColors]);
|
||||
|
||||
const animConfig = ANIMATION_TYPES[type];
|
||||
const Component = as;
|
||||
|
||||
return (
|
||||
<Component
|
||||
// @ts-expect-error - Dynamic component type requires flexible ref
|
||||
ref={textRef}
|
||||
className={cls(
|
||||
`${animConfig.classPrefix}-text`,
|
||||
className
|
||||
)}
|
||||
style={{
|
||||
fontKerning: 'none',
|
||||
textRendering: 'optimizeSpeed',
|
||||
}}
|
||||
aria-label={ariaLabel || text || undefined}
|
||||
>
|
||||
{children || text?.replace(/-/g, '\u2011')}
|
||||
</Component>
|
||||
);
|
||||
};
|
||||
|
||||
TextAnimation.displayName = "TextAnimation";
|
||||
|
||||
export default memo(TextAnimation);
|
||||
Reference in New Issue
Block a user