From d1c6e8722560031bc27ca2b9ab255e7a2c8f7d0f Mon Sep 17 00:00:00 2001 From: Matt Date: Mon, 22 Sep 2025 23:27:29 +0200 Subject: [PATCH] Optimize contact page animations for smooth performance - Replace React state with refs to eliminate re-renders - Add RAF throttling for scroll events - Implement GPU acceleration with transform3d - Fix logo positioning and flickering issues - Optimize mobile button text display - Add memoization for expensive calculations - Improve performance from 60+ re-renders/sec to 60fps smooth animation --- src/app/contact/page.tsx | 346 ++++++++++++++++++++++++--------------- 1 file changed, 218 insertions(+), 128 deletions(-) diff --git a/src/app/contact/page.tsx b/src/app/contact/page.tsx index b38a900..6931132 100644 --- a/src/app/contact/page.tsx +++ b/src/app/contact/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, useEffect, useRef } from 'react'; +import { useState, useEffect, useRef, useMemo } from 'react'; import Image from 'next/image'; import { useMediaQuery } from '@react-hook/media-query'; import { ChevronDown, Phone, Mail } from 'lucide-react'; @@ -27,17 +27,20 @@ const formSchema = z.object({ export default function ContactPage() { const [mounted, setMounted] = useState(false); - const [logoPosition, setLogoPosition] = useState('center'); - const [logoStyle, setLogoStyle] = useState({}); - const [buttonOpacity, setButtonOpacity] = useState(1); - const [chevronOpacity, setChevronOpacity] = useState(1); const [contactTop, setContactTop] = useState(0); const [windowHeight, setWindowHeight] = useState(0); - + + // Animation values as refs to avoid re-renders + const logoPositionRef = useRef('center'); + const buttonRef = useRef(null); + const chevronRef = useRef(null); + const contactSectionRef = useRef(null); const logoRef = useRef(null); const animationFrameRef = useRef(null); const lastScrollY = useRef(0); + const lastFrameTime = useRef(0); + const ticking = useRef(false); const isMobile = useMediaQuery("(max-width: 768px)"); const isDesktop = useMediaQuery("(min-width: 1280px)"); @@ -58,9 +61,33 @@ export default function ContactPage() { console.log(values); } - // Logo dimensions based on screen size - const logoWidth = isMobile ? 240 : isDesktop ? 316 : 280; - const logoHeight = isMobile ? 115 : isDesktop ? 151 : 134; + // Memoized logo dimensions based on screen size + const logoWidth = useMemo(() => + isMobile ? 240 : isDesktop ? 316 : 280, + [isMobile, isDesktop] + ); + + const logoHeight = useMemo(() => + isMobile ? 115 : isDesktop ? 151 : 134, + [isMobile, isDesktop] + ); + + // Memoized animation constants + const animationConstants = useMemo(() => ({ + mobile: { + targetTopPosition: 10, + startYFactor: 0.30, // Moved up from 0.35 to account for single-line button + finalScale: 0.5, + fadeThreshold: 5 + }, + desktop: { + targetTopPosition: 20, + logoSpeed: 0.4, + maxScroll: 500, + scaleReduction: 0.6, + fadeThreshold: 10 + } + }), []); useEffect(() => { setMounted(true); @@ -71,7 +98,128 @@ export default function ContactPage() { } }, []); - const updateLogoPosition = () => { + // Helper function to update element styles directly + const updateElementStyle = (element: HTMLElement | null, styles: Partial) => { + if (!element) return; + Object.assign(element.style, styles); + }; + + // Separated mobile animation logic + const calculateMobileLogoStyles = ( + scrollY: number, + windowHeight: number, + contactTop: number, + logoHeight: number + ): Partial => { + const { targetTopPosition, startYFactor, finalScale } = animationConstants.mobile; + const startY = windowHeight * startYFactor; + const endY = targetTopPosition + (logoHeight * finalScale) / 2; + const totalDistance = startY - endY; + const animationEndScroll = contactTop; + + if (scrollY >= animationEndScroll) { + logoPositionRef.current = 'top'; + const scrollPastEnd = scrollY - animationEndScroll; + const fixedY = endY - scrollPastEnd; + const mobileScale = finalScale; + + return { + position: 'fixed', + top: `${fixedY}px`, + left: '50%', + transform: `translate3d(-50%, 0, 0) scale3d(${mobileScale}, ${mobileScale}, 1) translateZ(0)`, + transformOrigin: 'center', + willChange: scrollY > 0 ? 'transform' : 'auto', + backfaceVisibility: 'hidden', + transition: 'none', + zIndex: '50' + }; + } else if (scrollY > 0) { + logoPositionRef.current = 'animating'; + const progress = scrollY / animationEndScroll; + const currentY = startY - (totalDistance * progress); + const mobileScale = 1 - ((1 - finalScale) * progress); + + return { + position: 'fixed', + top: `${currentY}px`, + left: '50%', + transform: `translate3d(-50%, 0, 0) scale3d(${mobileScale}, ${mobileScale}, 1) translateZ(0)`, + transformOrigin: 'center', + willChange: 'transform', + backfaceVisibility: 'hidden', + transition: 'none', + zIndex: '50' + }; + } else { + logoPositionRef.current = 'center'; + return { + position: 'fixed', + top: `${startY}px`, + left: '50%', + transform: 'translate3d(-50%, 0, 0) scale3d(1, 1, 1) translateZ(0)', + transformOrigin: 'center', + willChange: 'auto', + backfaceVisibility: 'hidden', + transition: 'none', + zIndex: '50' + }; + } + }; + + // Separated desktop animation logic + const calculateDesktopLogoStyles = ( + scrollY: number, + windowHeight: number, + logoHeight: number + ): Partial => { + const { targetTopPosition, logoSpeed, maxScroll, scaleReduction } = animationConstants.desktop; + const centerY = windowHeight / 2; + const totalDistance = centerY - targetTopPosition - logoHeight / 2; + const logoYPosition = -(scrollY * logoSpeed); + const maxUpwardMovement = -totalDistance; + const animatedY = Math.max(logoYPosition, maxUpwardMovement); + + const scrollProgress = Math.min(scrollY / maxScroll, 1); + const scale = 1 - (scaleReduction * scrollProgress); + + logoPositionRef.current = scrollY > 10 ? 'animating' : 'center'; + + return { + position: 'fixed', + top: '50%', + left: '50%', + transform: `translate3d(-50%, calc(-50% + ${animatedY}px), 0) scale3d(${scale}, ${scale}, 1) translateZ(0)`, + transformOrigin: 'center', + willChange: scrollY > 0 ? 'transform' : 'auto', + backfaceVisibility: 'hidden', + transition: 'none', + zIndex: '50' + }; + }; + + // Update button and chevron opacity + const updateControlsOpacity = (scrollY: number, isMobile: boolean) => { + const fadeThreshold = isMobile ? animationConstants.mobile.fadeThreshold : animationConstants.desktop.fadeThreshold; + const opacity = scrollY > fadeThreshold ? 0 : 1; + + if (buttonRef.current) { + buttonRef.current.style.opacity = String(opacity); + buttonRef.current.style.pointerEvents = opacity > 0 ? 'auto' : 'none'; + } + + if (chevronRef.current) { + chevronRef.current.style.opacity = String(opacity); + } + }; + + const updateLogoPosition = (timestamp: number = performance.now()) => { + // Throttle to 60fps (16ms minimum between frames) + if (timestamp - lastFrameTime.current < 16) { + return; + } + lastFrameTime.current = timestamp; + if (!contactSectionRef.current) return; const scrollY = window.scrollY; @@ -86,112 +234,15 @@ export default function ContactPage() { setWindowHeight(currentWindowHeight); } - // Calculate positions - adjusted for scaled logo - const targetTopPosition = isMobile ? 10 : 20; // Adjusted for smaller scaled logo + // Calculate and apply logo styles based on device type + const logoStyles = isMobile + ? calculateMobileLogoStyles(scrollY, currentWindowHeight, currentContactTop, logoHeight) + : calculateDesktopLogoStyles(scrollY, currentWindowHeight, logoHeight); - if (isMobile) { - // For mobile, calculate where the logo should end up - const startY = currentWindowHeight * 0.35; // Logo starts higher - 35% from top - const endY = targetTopPosition + (logoHeight * 0.5) / 2; // Account for 50% scale - const totalDistance = startY - endY; + updateElementStyle(logoRef.current, logoStyles); - // Keep animation ending at the full contact section position - const animationEndScroll = currentContactTop; - - if (scrollY >= animationEndScroll) { - // Logo has reached destination - keep it fixed but move with scroll - setLogoPosition('top'); - - // Calculate position to simulate being part of the page - const scrollPastEnd = scrollY - animationEndScroll; - const fixedY = endY - scrollPastEnd; - const mobileScale = 0.5; // Final scale for mobile - - setLogoStyle({ - position: 'fixed', - top: `${fixedY}px`, - left: '50%', - transform: `translate3d(-50%, 0, 0) scale3d(${mobileScale}, ${mobileScale}, 1)`, - transformOrigin: 'center', - willChange: 'transform', - transition: 'none', - zIndex: 50 - }); - } else if (scrollY > 0) { - // Animate logo from center to destination - starts immediately at any scroll - const progress = scrollY / animationEndScroll; - const currentY = startY - (totalDistance * progress); - const mobileScale = 1 - (0.5 * progress); // Scale from 1.0 to 0.5 - - setLogoPosition('animating'); - setLogoStyle({ - position: 'fixed', - top: `${currentY}px`, - left: '50%', - transform: `translate3d(-50%, 0, 0) scale3d(${mobileScale}, ${mobileScale}, 1)`, - transformOrigin: 'center', - willChange: 'transform', - transition: 'none', - zIndex: 50 - }); - } else { - // At the top - logo at starting position - setLogoPosition('center'); - setLogoStyle({ - position: 'fixed', - top: `${startY}px`, - left: '50%', - transform: 'translate3d(-50%, 0, 0) scale3d(1, 1, 1)', - transformOrigin: 'center', - willChange: 'transform', - transition: 'none', - zIndex: 50 - }); - } - } else { - // Desktop - standard animation with scaling - const logoSpeed = 0.4; - const centerY = currentWindowHeight / 2; - const targetTopPosition = 20; // Reduced from 100px to account for smaller logo - const totalDistance = centerY - targetTopPosition - logoHeight / 2; - const logoYPosition = -(scrollY * logoSpeed); - const maxUpwardMovement = -totalDistance; - const animatedY = Math.max(logoYPosition, maxUpwardMovement); - - // Calculate scale based on scroll progress - const maxScroll = 500; // Scroll distance at which scaling completes - const scrollProgress = Math.min(scrollY / maxScroll, 1); - const scale = 1 - (0.6 * scrollProgress); // Scale from 1.0 to 0.4 - - // Update state based on scroll - if (scrollY > 10) { - setLogoPosition('animating'); - } else { - setLogoPosition('center'); - } - - // Fixed positioning with animation and scaling - setLogoStyle({ - position: 'fixed', - top: '50%', - left: '50%', - transform: `translate3d(-50%, calc(-50% + ${animatedY}px), 0) scale3d(${scale}, ${scale}, 1)`, - transformOrigin: 'center', - willChange: 'transform', - transition: 'none', - zIndex: 50 - }); - } - - // Hide button and chevron - faster on mobile - const fadeThreshold = isMobile ? 5 : 10; // Fade out at just 5px scroll on mobile - if (scrollY > fadeThreshold) { - setButtonOpacity(0); - setChevronOpacity(0); - } else { - setButtonOpacity(1); - setChevronOpacity(1); - } + // Update controls opacity + updateControlsOpacity(scrollY, isMobile); }; // Animated scroll to form @@ -212,17 +263,15 @@ export default function ContactPage() { // Add scroll listener for bidirectional animation with RAF useEffect(() => { - let ticking = false; - const handleScroll = () => { lastScrollY.current = window.scrollY; - - if (!ticking) { - animationFrameRef.current = requestAnimationFrame(() => { - updateLogoPosition(); - ticking = false; + + if (!ticking.current) { + ticking.current = true; + animationFrameRef.current = requestAnimationFrame((timestamp) => { + updateLogoPosition(timestamp); + ticking.current = false; }); - ticking = true; } }; @@ -248,6 +297,48 @@ export default function ContactPage() { }; }, [isMobile, isDesktop, logoHeight, windowHeight]); + // Store if initial position has been set + const initialPositionSet = useRef(false); + + // Calculate initial logo position to prevent teleport and flicker + const initialLogoStyle = useMemo(() => { + // Only set initial styles once and before JS takes over + if (!mounted || initialPositionSet.current) return {}; + + // For mobile, position at the calculated start position + if (isMobile && windowHeight > 0) { + const initialTop = windowHeight * animationConstants.mobile.startYFactor; + initialPositionSet.current = true; + + return { + position: 'fixed' as const, + top: `${initialTop}px`, + left: '50%', + transform: 'translate3d(-50%, 0, 0) translateZ(0)', + willChange: 'auto', + backfaceVisibility: 'hidden' as const, + zIndex: 50 + }; + } + + // For desktop, center the logo properly + if (!isMobile && mounted) { + initialPositionSet.current = true; + + return { + position: 'fixed' as const, + top: '50%', + left: '50%', + transform: 'translate3d(-50%, -50%, 0) translateZ(0)', + willChange: 'auto', + backfaceVisibility: 'hidden' as const, + zIndex: 50 + }; + } + + return {}; + }, [isMobile, mounted, windowHeight, animationConstants.mobile.startYFactor]); + // Don't render until mounted to avoid hydration mismatch if (!mounted) { return null; @@ -261,7 +352,7 @@ export default function ContactPage() {
0 ? 'auto' : 'none', transition: 'opacity 0.3s ease-out' }} > @@ -294,13 +384,13 @@ export default function ContactPage() { {/* Chevron Down - with fade out on scroll */} -