83 lines
2.3 KiB
TypeScript
83 lines
2.3 KiB
TypeScript
"use client";
|
|
|
|
/**
|
|
* KineticText — word/line masked reveal driven by ScrollTrigger.
|
|
*
|
|
* Splits the given text into words wrapped in overflow-hidden masks (manual
|
|
* spans — no premium SplitText plugin) and slides each word up from below as it
|
|
* enters the viewport, with a stagger. Renders the text as real, selectable DOM
|
|
* so it stays accessible and SEO-safe; animation only transforms (perf-friendly).
|
|
*
|
|
* With reduced-motion, the text simply appears (no transform).
|
|
*/
|
|
|
|
import { createElement, useEffect, useRef, type ElementType } from "react";
|
|
import { gsap } from "gsap";
|
|
import { ScrollTrigger } from "gsap/ScrollTrigger";
|
|
|
|
gsap.registerPlugin(ScrollTrigger);
|
|
|
|
type Props = {
|
|
text: string;
|
|
as?: ElementType;
|
|
className?: string;
|
|
/** Delay the whole reveal (s). */
|
|
delay?: number;
|
|
/** Start animation on mount instead of on scroll (for the hero). */
|
|
immediate?: boolean;
|
|
/** Mark a word index range as gradient-highlighted. */
|
|
highlight?: [number, number];
|
|
};
|
|
|
|
export default function KineticText({
|
|
text,
|
|
as: Tag = "span",
|
|
className = "",
|
|
delay = 0,
|
|
immediate = false,
|
|
highlight,
|
|
}: Props) {
|
|
const ref = useRef<HTMLElement>(null);
|
|
const words = text.split(" ");
|
|
|
|
useEffect(() => {
|
|
const el = ref.current;
|
|
if (!el) return;
|
|
const inner = gsap.utils.toArray<HTMLElement>(".ktext__in", el);
|
|
|
|
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
|
|
gsap.set(inner, { yPercent: 0, opacity: 1 });
|
|
return;
|
|
}
|
|
|
|
const ctx = gsap.context(() => {
|
|
gsap.set(inner, { yPercent: 118 });
|
|
const anim: gsap.TweenVars = {
|
|
yPercent: 0,
|
|
duration: 1.0,
|
|
ease: "power4.out",
|
|
stagger: 0.045,
|
|
delay,
|
|
};
|
|
if (!immediate) {
|
|
anim.scrollTrigger = { trigger: el, start: "top 88%" };
|
|
}
|
|
gsap.to(inner, anim);
|
|
}, el);
|
|
|
|
return () => ctx.revert();
|
|
}, [delay, immediate]);
|
|
|
|
const children = words.map((w, i) => {
|
|
const hot =
|
|
highlight && i >= highlight[0] && i <= highlight[1] ? " ktext__word--grad" : "";
|
|
return (
|
|
<span className={`ktext__word${hot}`} key={i}>
|
|
<span className="ktext__in">{w}</span>
|
|
{i < words.length - 1 ? " " : ""}
|
|
</span>
|
|
);
|
|
});
|
|
|
|
return createElement(Tag, { ref, className: `ktext ${className}` }, children);
|
|
}
|