Optimize contact page animations for smooth performance
Build And Push Image / docker (push) Successful in 1m56s Details

- 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
This commit is contained in:
Matt 2025-09-22 23:27:29 +02:00
parent eda3fb4522
commit d1c6e87225
1 changed files with 218 additions and 128 deletions

View File

@ -1,6 +1,6 @@
'use client'; 'use client';
import { useState, useEffect, useRef } from 'react'; import { useState, useEffect, useRef, useMemo } from 'react';
import Image from 'next/image'; import Image from 'next/image';
import { useMediaQuery } from '@react-hook/media-query'; import { useMediaQuery } from '@react-hook/media-query';
import { ChevronDown, Phone, Mail } from 'lucide-react'; import { ChevronDown, Phone, Mail } from 'lucide-react';
@ -27,17 +27,20 @@ const formSchema = z.object({
export default function ContactPage() { export default function ContactPage() {
const [mounted, setMounted] = useState(false); const [mounted, setMounted] = useState(false);
const [logoPosition, setLogoPosition] = useState('center');
const [logoStyle, setLogoStyle] = useState<React.CSSProperties>({});
const [buttonOpacity, setButtonOpacity] = useState(1);
const [chevronOpacity, setChevronOpacity] = useState(1);
const [contactTop, setContactTop] = useState(0); const [contactTop, setContactTop] = useState(0);
const [windowHeight, setWindowHeight] = useState(0); const [windowHeight, setWindowHeight] = useState(0);
// Animation values as refs to avoid re-renders
const logoPositionRef = useRef('center');
const buttonRef = useRef<HTMLButtonElement>(null);
const chevronRef = useRef<HTMLDivElement>(null);
const contactSectionRef = useRef<HTMLDivElement>(null); const contactSectionRef = useRef<HTMLDivElement>(null);
const logoRef = useRef<HTMLDivElement>(null); const logoRef = useRef<HTMLDivElement>(null);
const animationFrameRef = useRef<number | null>(null); const animationFrameRef = useRef<number | null>(null);
const lastScrollY = useRef(0); const lastScrollY = useRef(0);
const lastFrameTime = useRef(0);
const ticking = useRef(false);
const isMobile = useMediaQuery("(max-width: 768px)"); const isMobile = useMediaQuery("(max-width: 768px)");
const isDesktop = useMediaQuery("(min-width: 1280px)"); const isDesktop = useMediaQuery("(min-width: 1280px)");
@ -58,9 +61,33 @@ export default function ContactPage() {
console.log(values); console.log(values);
} }
// Logo dimensions based on screen size // Memoized logo dimensions based on screen size
const logoWidth = isMobile ? 240 : isDesktop ? 316 : 280; const logoWidth = useMemo(() =>
const logoHeight = isMobile ? 115 : isDesktop ? 151 : 134; 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(() => { useEffect(() => {
setMounted(true); 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<CSSStyleDeclaration>) => {
if (!element) return;
Object.assign(element.style, styles);
};
// Separated mobile animation logic
const calculateMobileLogoStyles = (
scrollY: number,
windowHeight: number,
contactTop: number,
logoHeight: number
): Partial<CSSStyleDeclaration> => {
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<CSSStyleDeclaration> => {
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; if (!contactSectionRef.current) return;
const scrollY = window.scrollY; const scrollY = window.scrollY;
@ -86,112 +234,15 @@ export default function ContactPage() {
setWindowHeight(currentWindowHeight); setWindowHeight(currentWindowHeight);
} }
// Calculate positions - adjusted for scaled logo // Calculate and apply logo styles based on device type
const targetTopPosition = isMobile ? 10 : 20; // Adjusted for smaller scaled logo const logoStyles = isMobile
? calculateMobileLogoStyles(scrollY, currentWindowHeight, currentContactTop, logoHeight)
: calculateDesktopLogoStyles(scrollY, currentWindowHeight, logoHeight);
if (isMobile) { updateElementStyle(logoRef.current, logoStyles);
// 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;
// Keep animation ending at the full contact section position // Update controls opacity
const animationEndScroll = currentContactTop; updateControlsOpacity(scrollY, isMobile);
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);
}
}; };
// Animated scroll to form // Animated scroll to form
@ -212,17 +263,15 @@ export default function ContactPage() {
// Add scroll listener for bidirectional animation with RAF // Add scroll listener for bidirectional animation with RAF
useEffect(() => { useEffect(() => {
let ticking = false;
const handleScroll = () => { const handleScroll = () => {
lastScrollY.current = window.scrollY; lastScrollY.current = window.scrollY;
if (!ticking) { if (!ticking.current) {
animationFrameRef.current = requestAnimationFrame(() => { ticking.current = true;
updateLogoPosition(); animationFrameRef.current = requestAnimationFrame((timestamp) => {
ticking = false; updateLogoPosition(timestamp);
ticking.current = false;
}); });
ticking = true;
} }
}; };
@ -248,6 +297,48 @@ export default function ContactPage() {
}; };
}, [isMobile, isDesktop, logoHeight, windowHeight]); }, [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 // Don't render until mounted to avoid hydration mismatch
if (!mounted) { if (!mounted) {
return null; return null;
@ -261,7 +352,7 @@ export default function ContactPage() {
<div <div
ref={logoRef} ref={logoRef}
className="z-50" className="z-50"
style={logoStyle} style={initialLogoStyle}
> >
<Image <Image
src="/logo.png" src="/logo.png"
@ -279,14 +370,13 @@ export default function ContactPage() {
{/* Button with fade out on scroll */} {/* Button with fade out on scroll */}
<button <button
ref={buttonRef}
onClick={scrollToForm} onClick={scrollToForm}
className={`fixed z-30 px-8 py-3 bg-[#C6AE97] text-[#1B233B] font-['bill_corporate_medium'] font-normal text-base uppercase tracking-wider rounded-md hover:bg-[#D4C1AC] transition-all`} className={`fixed z-30 px-6 py-3 bg-[#C6AE97] text-[#1B233B] font-['bill_corporate_medium'] font-normal text-sm md:text-base uppercase tracking-wider rounded-md hover:bg-[#D4C1AC] transition-colors whitespace-nowrap`}
style={{ style={{
bottom: '120px', bottom: '120px',
left: '50%', left: '50%',
transform: 'translateX(-50%)', transform: 'translateX(-50%)',
opacity: buttonOpacity,
pointerEvents: buttonOpacity > 0 ? 'auto' : 'none',
transition: 'opacity 0.3s ease-out' transition: 'opacity 0.3s ease-out'
}} }}
> >
@ -294,13 +384,13 @@ export default function ContactPage() {
</button> </button>
{/* Chevron Down - with fade out on scroll */} {/* Chevron Down - with fade out on scroll */}
<div <div
ref={chevronRef}
className="fixed z-20" className="fixed z-20"
style={{ style={{
bottom: '40px', bottom: '40px',
left: '50%', left: '50%',
transform: 'translateX(-50%)', transform: 'translateX(-50%)',
opacity: chevronOpacity,
transition: 'opacity 0.3s ease-out' transition: 'opacity 0.3s ease-out'
}} }}
> >