Files
Evandro Fonseca Junior 08dd046887 feat: add animated components for enhanced UI/UX
- Introduced DrawLine component for drawing animations.
- Added LazyRevealImage component for lazy loading images with reveal animations.
- Implemented TextDiffuseReveal for animated text reveals with staggered effects.
- Created TopProgressBar for a dynamic loading indicator.
- Added PointCloud mock for testing animations.
- Developed useReducedMotion hook to handle user preferences for reduced motion.
- Centralized animation configurations in config.ts for easier management.
- Defined animation variants in variants.ts for consistent animation effects.
- Established AnimationProvider to supply global animation settings.
- Created tests for AnimationProvider and useReducedMotion hook.
- Added SVG assets for branding and UI elements.
- Documented frontend review plan and hero circuit board animation plan.
2026-03-15 21:06:43 -03:00

134 lines
3.7 KiB
TypeScript

"use client";
import { motion, useInView, useReducedMotion } from "framer-motion";
import { useEffect, useMemo, useRef, useState } from "react";
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(() => text.split(/(\n|\s+)/), [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}
aria-label={text}
style={{
willChange: hasActiveWillChange
? "filter, opacity, transform"
: undefined,
}}
>
{tokens.map((token, tokenIndex) => {
if (!token) {
return null;
}
if (token === "\n") {
return <br key={`line-${tokenIndex}`} />;
}
if (/^\s+$/.test(token)) {
return (
<span key={`space-${tokenIndex}`} aria-hidden="true">
{token.replace(/ /g, "\u00A0")}
</span>
);
}
return (
<span
key={`word-${tokenIndex}`}
className="inline-block whitespace-nowrap"
>
{Array.from(token).map((char, charIndex) => {
const currentIndex = animatedIndex;
animatedIndex += 1;
return (
<motion.span
key={`char-${tokenIndex}-${charIndex}-${char}`}
className="inline-block"
initial={
shouldReduceMotion
? { opacity: 0 }
: { filter: "blur(12px)", opacity: 0, scale: 0.9 }
}
animate={
isInView
? shouldReduceMotion
? { opacity: 1 }
: { filter: "blur(0px)", opacity: 1, scale: 1 }
: shouldReduceMotion
? { opacity: 0 }
: { 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>
);
}