From bff759229370b1aa83a332507c4d9be37880c3e2 Mon Sep 17 00:00:00 2001 From: "netlify[bot]" Date: Thu, 5 Feb 2026 19:20:25 +0000 Subject: [PATCH] Audit and Categorize Animations in React/Next.js Project Using Framer Motion, GSAP, and Three.js (6984e9a7c7f21bab16156c2d) --- app/projects/eternalcore/eternal-showcase.tsx | 33 ++++++--- app/projects/eternalcore/page.tsx | 55 ++++++++++++-- bun.lock | 7 ++ components/home/projects/projects-section.tsx | 73 +++++++++++++------ hooks/use-auto-scroll.ts | 68 +++++++++++++++++ lib/animations/variants.ts | 3 + package.json | 1 + 7 files changed, 203 insertions(+), 37 deletions(-) create mode 100644 hooks/use-auto-scroll.ts diff --git a/app/projects/eternalcore/eternal-showcase.tsx b/app/projects/eternalcore/eternal-showcase.tsx index ba7f57cc..4f19b217 100644 --- a/app/projects/eternalcore/eternal-showcase.tsx +++ b/app/projects/eternalcore/eternal-showcase.tsx @@ -1,11 +1,12 @@ "use client"; -import { AnimatePresence, motion } from "framer-motion"; +import { AnimatePresence, motion, type Transition } from "framer-motion"; import { Maximize2, X } from "lucide-react"; import Image from "next/image"; import { useEffect, useState } from "react"; import { createPortal } from "react-dom"; import { FadeIn } from "@/components/ui/motion/motion-components"; +import { useReducedMotion } from "@/hooks/use-reduced-motion"; const marqueeItems = [ // Row 1: Essentials & Chat @@ -117,29 +118,38 @@ const InfiniteMarquee = ({ items, direction = "left", speed = 40, + prefersReducedMotion, onItemClick, }: { items: typeof marqueeItems; direction?: "left" | "right"; speed?: number; + prefersReducedMotion: boolean; onItemClick: (item: (typeof marqueeItems)[0]) => void; }) => { + const marqueeAnimation = prefersReducedMotion + ? undefined + : { + x: direction === "left" ? ["0%", "-50%"] : ["-50%", "0%"], + }; + const marqueeTransition: Transition | undefined = prefersReducedMotion + ? undefined + : { + duration: speed, + ease: "linear", + repeat: Number.POSITIVE_INFINITY, + repeatType: "loop", + }; + return (
{[...items, ...items].map((item, i) => ( (null); const midPoint = Math.ceil(marqueeItems.length / 2); + const prefersReducedMotion = useReducedMotion(); return ( <> @@ -203,12 +214,14 @@ export function EternalShowcase() { direction="left" items={marqueeItems.slice(0, midPoint)} onItemClick={setSelectedItem} + prefersReducedMotion={prefersReducedMotion} speed={50} /> diff --git a/app/projects/eternalcore/page.tsx b/app/projects/eternalcore/page.tsx index fef6b8fd..54b9e8ea 100644 --- a/app/projects/eternalcore/page.tsx +++ b/app/projects/eternalcore/page.tsx @@ -1,20 +1,24 @@ "use client"; import { m, motion, useScroll, useTransform } from "framer-motion"; +import { gsap } from "gsap"; import { Book, Check, ChevronRight, Download, Layers, Settings, Zap } from "lucide-react"; import Image from "next/image"; -import { useRef } from "react"; +import { useEffect, useRef } from "react"; import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; import { FacadePattern } from "@/components/ui/facade-pattern"; import { FadeIn, MotionSection, ScaleIn, SlideIn } from "@/components/ui/motion/motion-components"; import { slideUp } from "@/lib/animations/variants"; +import { useReducedMotion } from "@/hooks/use-reduced-motion"; import { ConfigPreview } from "./config-preview"; import { EternalShowcase } from "./eternal-showcase"; export default function EternalCorePage() { + const pageRef = useRef(null); const targetRef = useRef(null); + const prefersReducedMotion = useReducedMotion(); const { scrollYProgress } = useScroll({ target: targetRef, offset: ["start start", "end start"], @@ -23,13 +27,54 @@ export default function EternalCorePage() { const opacity = useTransform(scrollYProgress, [0, 0.5], [1, 0]); const scale = useTransform(scrollYProgress, [0, 0.5], [1, 0.95]); + useEffect(() => { + if (prefersReducedMotion) { + return; + } + + const root = pageRef.current; + if (!root) { + return; + } + + const ctx = gsap.context(() => { + const q = gsap.utils.selector(root); + const blobs = q("[data-ambient-blob]"); + + blobs.forEach((blob, index) => { + gsap.to(blob, { + x: index % 2 === 0 ? 24 : -24, + y: index % 2 === 0 ? -32 : 32, + duration: 14 + index * 2, + repeat: -1, + yoyo: true, + ease: "sine.inOut", + }); + }); + }, root); + + return () => ctx.revert(); + }, [prefersReducedMotion]); + return ( -
+
{/* Background Decor */}
-
-
-
+
+
+
diff --git a/bun.lock b/bun.lock index 3391bf45..ad8eac92 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "eternalcodev3", @@ -18,6 +19,7 @@ "focus-trap-react": "^11.0.6", "framer-motion": "^12.29.0", "gray-matter": "^4.0.3", + "gsap": "^3.14.2", "lenis": "^1.3.17", "lucide-react": "^0.563.0", "minimessage-js": "^1.2.1", @@ -31,6 +33,7 @@ "react-dom": "^19.2.3", "react-error-boundary": "^6.0.3", "react-hot-toast": "^2.6.0", + "react-icons": "^5.5.0", "react-intersection-observer": "^10.0.0", "rehype-prism-plus": "^2.0.1", "rehype-slug": "^6.0.0", @@ -674,6 +677,8 @@ "gray-matter": ["gray-matter@4.0.3", "", { "dependencies": { "js-yaml": "^3.13.1", "kind-of": "^6.0.2", "section-matter": "^1.0.0", "strip-bom-string": "^1.0.0" } }, "sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q=="], + "gsap": ["gsap@3.14.2", "", {}, "sha512-P8/mMxVLU7o4+55+1TCnQrPmgjPKnwkzkXOK1asnR9Jg2lna4tEY5qBJjMmAaOBDDZWtlRjBXjLa0w53G/uBLA=="], + "gzip-size": ["gzip-size@6.0.0", "", { "dependencies": { "duplexer": "^0.1.2" } }, "sha512-ax7ZYomf6jqPTQ4+XCpUGyXKHk5WweS+e05MBO4/y3WJ5RkmPXNKvX+bx1behVILVwr6JSQvZAku021CHPXG3Q=="], "hast-util-from-html": ["hast-util-from-html@2.0.3", "", { "dependencies": { "@types/hast": "^3.0.0", "devlop": "^1.1.0", "hast-util-from-parse5": "^8.0.0", "parse5": "^7.0.0", "vfile": "^6.0.0", "vfile-message": "^4.0.0" } }, "sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw=="], @@ -1032,6 +1037,8 @@ "react-hot-toast": ["react-hot-toast@2.6.0", "", { "dependencies": { "csstype": "^3.1.3", "goober": "^2.1.16" }, "peerDependencies": { "react": ">=16", "react-dom": ">=16" } }, "sha512-bH+2EBMZ4sdyou/DPrfgIouFpcRLCJ+HoCA32UoAYHn6T3Ur5yfcDCeSr5mwldl6pFOsiocmrXMuoCJ1vV8bWg=="], + "react-icons": ["react-icons@5.5.0", "", { "peerDependencies": { "react": "*" } }, "sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw=="], + "react-intersection-observer": ["react-intersection-observer@10.0.0", "", { "peerDependencies": { "react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["react-dom"] }, "sha512-JJRgcnFQoVXmbE5+GXr1OS1NDD1gHk0HyfpLcRf0575IbJz+io8yzs4mWVlfaqOQq1FiVjLvuYAdEEcrrCfveg=="], "react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], diff --git a/components/home/projects/projects-section.tsx b/components/home/projects/projects-section.tsx index 8bd95c25..4e1785d6 100644 --- a/components/home/projects/projects-section.tsx +++ b/components/home/projects/projects-section.tsx @@ -1,9 +1,12 @@ "use client"; import { motion } from "framer-motion"; +import { gsap } from "gsap"; import Image from "next/image"; import Link from "next/link"; import { useEffect, useRef, useState } from "react"; +import { useAutoScroll } from "@/hooks/use-auto-scroll"; +import { useReducedMotion } from "@/hooks/use-reduced-motion"; interface Project { name: string; @@ -52,44 +55,69 @@ const projects: Project[] = [ ]; export default function Projects() { + const sectionRef = useRef(null); const scrollRef = useRef(null); const [isHovered, setIsHovered] = useState(false); + const prefersReducedMotion = useReducedMotion(); + + useAutoScroll(scrollRef, { + speed: 36, + isPaused: isHovered, + prefersReducedMotion, + maxFrameRate: 30, + }); - // Auto-scroll logic useEffect(() => { - const scrollContainer = scrollRef.current; - if (!scrollContainer) { + if (prefersReducedMotion) { + return; + } + + const section = sectionRef.current; + if (!section) { return; } - let animationFrameId: number; - let scrollPos = scrollContainer.scrollLeft; - const speed = 0.6; // Slightly slower for better viewing of images + const ctx = gsap.context(() => { + const q = gsap.utils.selector(section); + const cards = q("[data-project-card]"); + if (!cards.length) { + return; + } - const scroll = () => { - if (!isHovered && scrollContainer) { - scrollPos += speed; + gsap.set(cards, { autoAlpha: 0, y: 24 }); - if (scrollPos >= scrollContainer.scrollWidth / 3) { - scrollPos = 0; - } + const observer = new IntersectionObserver( + ([entry]) => { + if (!entry?.isIntersecting) { + return; + } - scrollContainer.scrollLeft = scrollPos; - } else if (isHovered && scrollContainer) { - scrollPos = scrollContainer.scrollLeft; - } - animationFrameId = requestAnimationFrame(scroll); - }; + gsap.to(cards, { + autoAlpha: 1, + y: 0, + duration: 0.7, + ease: "power3.out", + stagger: 0.08, + clearProps: "opacity,transform", + }); + + observer.disconnect(); + }, + { threshold: 0.3 } + ); + + observer.observe(section); - animationFrameId = requestAnimationFrame(scroll); + return () => observer.disconnect(); + }, section); - return () => cancelAnimationFrame(animationFrameId); - }, [isHovered]); + return () => ctx.revert(); + }, [prefersReducedMotion]); const displayProjects = [...projects, ...projects, ...projects]; return ( -
+
{/* Main Container - Keeps everything aligned with navbar */}
{/* Header content */} @@ -155,6 +183,7 @@ function ProjectCard({ project }: { project: Project }) { return ( , + { speed, isPaused = false, prefersReducedMotion = false, maxFrameRate = 30 }: AutoScrollOptions +) { + const rafIdRef = useRef(null); + const lastTimeRef = useRef(null); + const scrollPosRef = useRef(0); + + useEffect(() => { + const element = ref.current; + if (!element) { + return; + } + + if (isPaused || prefersReducedMotion) { + scrollPosRef.current = element.scrollLeft; + return; + } + + const minFrameMs = 1000 / Math.max(1, maxFrameRate); + + const tick = (time: number) => { + if (!element) { + return; + } + + if (lastTimeRef.current === null) { + lastTimeRef.current = time; + } + + const delta = time - lastTimeRef.current; + if (delta >= minFrameMs) { + scrollPosRef.current += (speed * delta) / 1000; + const maxScroll = element.scrollWidth / 3; + + if (scrollPosRef.current >= maxScroll) { + scrollPosRef.current = 0; + } + + element.scrollLeft = scrollPosRef.current; + lastTimeRef.current = time; + } + + rafIdRef.current = requestAnimationFrame(tick); + }; + + rafIdRef.current = requestAnimationFrame(tick); + + return () => { + if (rafIdRef.current !== null) { + cancelAnimationFrame(rafIdRef.current); + } + rafIdRef.current = null; + lastTimeRef.current = null; + }; + }, [isPaused, maxFrameRate, prefersReducedMotion, ref, speed]); +} diff --git a/lib/animations/variants.ts b/lib/animations/variants.ts index 97adcbe5..f59de697 100644 --- a/lib/animations/variants.ts +++ b/lib/animations/variants.ts @@ -27,9 +27,11 @@ export const tapSpring: Transition = { export const fadeIn: Variants = { hidden: { opacity: 0, + willChange: "opacity", }, visible: (delay = 0) => ({ opacity: 1, + willChange: "auto", transition: { ...easeOut, delay }, }), }; @@ -131,6 +133,7 @@ export const containerStagger: Variants = { export const hoverScale: Variants = { initial: { scale: 1, + willChange: "auto", }, hover: { scale: 1.035, diff --git a/package.json b/package.json index 1f9105b9..7de4c393 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "focus-trap-react": "^11.0.6", "framer-motion": "^12.29.0", "gray-matter": "^4.0.3", + "gsap": "^3.14.2", "lenis": "^1.3.17", "lucide-react": "^0.563.0", "minimessage-js": "^1.2.1",