Files
portifolio-evandrodevbr/app/components/animations/TextDiffuseReveal.tsx
T

134 lines
3.9 KiB
TypeScript

"use client";
import { motion, useInView } from "motion/react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
interface TextDiffuseRevealProps {
text: string;
className?: string;
startDelay?: number;
once?: boolean;
amount?: number;
}
const EXPO_OUT = [0.16, 1, 0.3, 1] as const;
export function TextDiffuseReveal({
text,
className = "",
startDelay = 0,
once = true,
amount = 0.3,
}: Readonly<TextDiffuseRevealProps>) {
const ref = useRef<HTMLSpanElement | null>(null);
const shouldReduceMotion = useReducedMotion();
const isInView = useInView(ref, { once, amount });
const [staggerDelay, setStaggerDelay] = useState(0.03);
const [completedChars, setCompletedChars] = useState(0);
useEffect(() => {
const media = window.matchMedia("(max-width: 768px)");
const updateStagger = () => setStaggerDelay(media.matches ? 0.015 : 0.03);
updateStagger();
media.addEventListener("change", updateStagger);
return () => media.removeEventListener("change", updateStagger);
}, []);
const tokens = useMemo(() => {
let offset = 0;
return text
.split(/(\n|\s+)/)
.filter(Boolean)
.map((value) => {
const key = `${offset}:${value}`;
offset += value.length;
return { key, value };
});
}, [text]);
const animatedChars = useMemo(
() =>
Array.from(text).filter((char) => char !== "\n" && char !== " ").length,
[text],
);
useEffect(() => {
if (!isInView) {
setCompletedChars(0);
}
}, [isInView]);
const hasActiveWillChange =
!shouldReduceMotion && isInView && completedChars < animatedChars;
let animatedIndex = 0;
return (
<span
ref={ref}
className={className}
style={{
willChange: hasActiveWillChange
? "filter, opacity, transform"
: undefined,
}}
>
<span className="sr-only">{text}</span>
<span aria-hidden="true">
{tokens.map(({ key, value: token }) => {
if (token === "\n") {
return <br key={key} />;
}
if (/^\s+$/.test(token)) {
return <span key={key}>{token.replace(/ /g, "\u00A0")}</span>;
}
return (
<span key={key} className="inline-block whitespace-nowrap">
{Array.from(token).map((char) => {
const currentIndex = animatedIndex;
animatedIndex += 1;
return (
<motion.span
key={`${key}-${currentIndex}-${char}`}
className="inline-block"
initial={
shouldReduceMotion
? { opacity: 0 }
: { filter: "blur(12px)", opacity: 0, scale: 0.9 }
}
animate={
isInView
? { filter: "blur(0px)", opacity: 1, scale: 1 }
: shouldReduceMotion
? { filter: "blur(0px)", opacity: 0, scale: 1 }
: { filter: "blur(12px)", opacity: 0, scale: 0.9 }
}
transition={{
duration: shouldReduceMotion ? 0.3 : 0.6,
ease: EXPO_OUT,
delay: startDelay + currentIndex * staggerDelay,
}}
onAnimationComplete={() => {
if (!shouldReduceMotion) {
setCompletedChars((count) =>
count < animatedChars ? count + 1 : count,
);
}
}}
>
{char}
</motion.span>
);
})}
</span>
);
})}
</span>
</span>
);
}