64 lines
1.6 KiB
TypeScript
64 lines
1.6 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useRef } from "react";
|
|
import { gsap } from "gsap";
|
|
import SplitType from "split-type";
|
|
|
|
/**
|
|
* Splits the hero H1 into lines + chars and reveals them with a staggered
|
|
* mask-up on load — the orchestrated "page load" delight moment.
|
|
* Under reduced motion the text is simply shown as-is. The real text is
|
|
* always in the DOM (SplitType only re-wraps the same characters), so SEO
|
|
* and screen readers read the full heading.
|
|
*/
|
|
export default function KineticHeadline({
|
|
children,
|
|
className,
|
|
}: {
|
|
children: React.ReactNode;
|
|
className?: string;
|
|
}) {
|
|
const ref = useRef<HTMLHeadingElement>(null);
|
|
|
|
useEffect(() => {
|
|
const el = ref.current;
|
|
if (!el) return;
|
|
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
if (reduce) {
|
|
el.style.opacity = "1";
|
|
return;
|
|
}
|
|
|
|
const split = new SplitType(el, { types: "lines,words" });
|
|
el.style.opacity = "1";
|
|
|
|
// wrap each line in an overflow-hidden mask
|
|
split.lines?.forEach((line) => {
|
|
const mask = document.createElement("span");
|
|
mask.className = "line-mask";
|
|
line.parentNode?.insertBefore(mask, line);
|
|
mask.appendChild(line);
|
|
});
|
|
|
|
const tween = gsap.from(split.words || [], {
|
|
yPercent: 115,
|
|
opacity: 0,
|
|
rotateZ: 2,
|
|
duration: 1,
|
|
ease: "expo.out",
|
|
stagger: 0.04,
|
|
delay: 0.15,
|
|
});
|
|
|
|
return () => {
|
|
tween.kill();
|
|
split.revert();
|
|
};
|
|
}, []);
|
|
|
|
return (
|
|
<h1 ref={ref} className={className} style={{ opacity: 0 }}>
|
|
{children}
|
|
</h1>
|
|
);
|
|
}
|