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.
This commit is contained in:
Evandro Fonseca Junior committed 2026-03-15 21:06:43 -03:00
1 parent 0ab6930f92
commit 08dd046887
76 files changed
+21682 -1197

No files matched your search

@@ -0,0 +1,63 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useInView, useReducedMotion } from "framer-motion";
interface CounterValueProps {
value: number;
className?: string;
duration?: number;
prefix?: string;
suffix?: string;
once?: boolean;
amount?: number;
}
export function CounterValue({
value,
className = "",
duration = 1200,
prefix = "",
suffix = "",
once = true,
amount = 0.45,
}: Readonly<CounterValueProps>) {
const shouldReduceMotion = useReducedMotion();
const ref = useRef<HTMLSpanElement | null>(null);
const isInView = useInView(ref, { once, amount });
const [displayValue, setDisplayValue] = useState(0);
useEffect(() => {
if (!isInView) return;
if (shouldReduceMotion) {
setDisplayValue(value);
return;
}
const start = performance.now();
let rafId = 0;
const frame = (now: number) => {
const elapsed = now - start;
const progress = Math.min(elapsed / duration, 1);
const eased = 1 - Math.pow(1 - progress, 3);
setDisplayValue(Math.round(value * eased));
if (progress < 1) {
rafId = requestAnimationFrame(frame);
}
};
rafId = requestAnimationFrame(frame);
return () => cancelAnimationFrame(rafId);
}, [isInView, value, duration, shouldReduceMotion]);
return (
<span ref={ref} className={className}>
{prefix}
{displayValue}
{suffix}
</span>
);
}
+39
View File
@@ -0,0 +1,39 @@
"use client";
import { useState } from "react";
import { motion, useReducedMotion } from "framer-motion";
interface DrawLineProps {
className?: string;
delay?: number;
}
export function DrawLine({
className = "",
delay = 0,
}: Readonly<DrawLineProps>) {
const shouldReduceMotion = useReducedMotion();
const [animating, setAnimating] = useState(!shouldReduceMotion);
return (
<motion.span
className={`ds-draw-line ${className}`.trim()}
initial={shouldReduceMotion ? { opacity: 0 } : { opacity: 0, scaleX: 0 }}
whileInView={
shouldReduceMotion ? { opacity: 1 } : { opacity: 1, scaleX: 1 }
}
viewport={{ once: true, amount: 0.7 }}
transition={{
duration: shouldReduceMotion ? 0.25 : 0.6,
delay,
ease: [0.16, 1, 0.3, 1],
}}
style={{
transformOrigin: "left",
willChange:
animating && !shouldReduceMotion ? "transform, opacity" : undefined,
}}
onAnimationComplete={() => setAnimating(false)}
/>
);
}
@@ -0,0 +1,70 @@
"use client";
import { useState } from "react";
import Image, { type StaticImageData } from "next/image";
import { motion, useReducedMotion } from "framer-motion";
interface LazyRevealImageProps {
src: string | StaticImageData;
alt: string;
className?: string;
imageClassName?: string;
sizes?: string;
}
export function LazyRevealImage({
src,
alt,
className = "",
imageClassName = "object-cover",
sizes = "(max-width: 1024px) 100vw, 50vw",
}: Readonly<LazyRevealImageProps>) {
const shouldReduceMotion = useReducedMotion();
const [loaded, setLoaded] = useState(false);
const [animating, setAnimating] = useState(!shouldReduceMotion);
return (
<motion.div
className={`relative ${className}`.trim()}
initial={
shouldReduceMotion
? { opacity: 0 }
: { opacity: 0, filter: "blur(20px)", scale: 1.05 }
}
animate={
loaded
? shouldReduceMotion
? { opacity: 1 }
: { opacity: 1, filter: "blur(0px)", scale: 1 }
: shouldReduceMotion
? { opacity: 0 }
: { opacity: 0, filter: "blur(20px)", scale: 1.05 }
}
transition={{
duration: shouldReduceMotion ? 0.25 : 0.75,
ease: [0.16, 1, 0.3, 1],
}}
style={{
willChange:
animating && !shouldReduceMotion
? "filter, transform, opacity"
: undefined,
}}
onAnimationComplete={() => setAnimating(false)}
>
<Image
src={src}
alt={alt}
fill
loading="lazy"
sizes={sizes}
placeholder={typeof src === "string" ? "empty" : "blur"}
onLoad={() => {
setAnimating(true);
setLoaded(true);
}}
className={imageClassName}
/>
</motion.div>
);
}
@@ -0,0 +1,133 @@
"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>
);
}
@@ -0,0 +1,40 @@
"use client";
import { motion, useReducedMotion } from "framer-motion";
interface TopProgressBarProps {
className?: string;
}
export function TopProgressBar({
className = "",
}: Readonly<TopProgressBarProps>) {
const shouldReduceMotion = useReducedMotion();
return (
<div
className={`pointer-events-none fixed inset-x-0 top-0 z-[120] h-[2px] overflow-hidden bg-white/10 ${className}`.trim()}
aria-hidden="true"
>
<motion.div
className="h-full w-1/3 bg-[var(--slash)] shadow-[0_0_12px_rgba(204,255,0,0.85)]"
initial={shouldReduceMotion ? { opacity: 1, x: "0%" } : { x: "-120%" }}
animate={
shouldReduceMotion
? { opacity: 1, x: "0%" }
: { x: ["-120%", "15%", "45%", "110%"] }
}
transition={
shouldReduceMotion
? { duration: 0 }
: {
duration: 1.5,
ease: [0.16, 1, 0.3, 1],
repeat: Number.POSITIVE_INFINITY,
repeatDelay: 0.1,
}
}
/>
</div>
);
}
@@ -0,0 +1,4 @@
import React from 'react';
export default function PointCloudScene() {
return <div data-testid="point-cloud-mock"></div>;
}