united-tattoo/hooks/use-parallax.ts
Nicholai 895f3dd24c
Some checks failed
CI / build-and-test (pull_request) Failing after 1m21s
Parallax Adjustments
2025-09-20 05:29:57 -06:00

222 lines
6.1 KiB
TypeScript

"use client"
import { useEffect, useRef, useCallback, useState } from "react"
import {
PARALLAX_CONFIG,
prefersReducedMotion,
getParallaxDepth,
PerformanceMonitor
} from "@/lib/parallax-config"
interface ParallaxOptions {
depth?: number
disabled?: boolean
rootMargin?: string
threshold?: number
}
interface ParallaxReturn {
ref: React.RefObject<HTMLDivElement>
style: React.CSSProperties
}
/**
* Custom hook for parallax scrolling effects
* Respects prefers-reduced-motion and performance constraints
*/
export function useParallax(options: ParallaxOptions = {}): ParallaxReturn {
const {
depth = PARALLAX_CONFIG.depth.background,
disabled = false,
rootMargin = "0px",
threshold = 0.1,
} = options
const elementRef = useRef<HTMLDivElement>(null)
const rafRef = useRef<number>()
const lastScrollY = useRef(0)
const isInView = useRef(false)
// Calculate effective depth based on accessibility preferences
const effectiveDepth = getParallaxDepth(depth)
const shouldAnimate = !disabled && !prefersReducedMotion() && effectiveDepth !== 0
const updateTransform = useCallback(() => {
if (!elementRef.current || !shouldAnimate || !isInView.current) return
PerformanceMonitor.start()
const scrollY = window.pageYOffset
const rect = elementRef.current.getBoundingClientRect()
const elementHeight = elementRef.current.offsetHeight
const windowHeight = window.innerHeight
// Calculate if element is in viewport with some buffer
const elementTop = rect.top + scrollY
const elementBottom = elementTop + elementHeight
const viewportTop = scrollY
const viewportBottom = scrollY + windowHeight
// Only animate if element is near viewport
if (elementBottom < viewportTop - windowHeight || elementTop > viewportBottom + windowHeight) {
isInView.current = false
return
}
// Calculate parallax offset using getBoundingClientRect().top
let parallaxOffset = -rect.top * effectiveDepth
// Clamp offset to avoid large translations that could push panels offscreen
// Use element height as a reasonable bound (50% of element height)
const maxOffset = elementHeight * 0.5
if (parallaxOffset > maxOffset) parallaxOffset = maxOffset
if (parallaxOffset < -maxOffset) parallaxOffset = -maxOffset
// Apply transform using CSS custom property for better performance
elementRef.current.style.setProperty('--parallax-offset', `${parallaxOffset}px`)
PerformanceMonitor.end('parallax-transform')
lastScrollY.current = scrollY
}, [shouldAnimate, effectiveDepth])
const throttledUpdate = useCallback(() => {
if (rafRef.current) return
rafRef.current = requestAnimationFrame(() => {
updateTransform()
rafRef.current = undefined
})
}, [updateTransform])
useEffect(() => {
if (!shouldAnimate) return
// Initialize CSS variable to 0
if (elementRef.current) {
elementRef.current.style.setProperty('--parallax-offset', '0px')
}
// Set up intersection observer to track when element is near viewport
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
isInView.current = entry.isIntersecting
// Only run an immediate transform if the page is already scrolled.
// This avoids applying different initial transforms for left/right panels
// when the page loads at the top — keeping both panels visually aligned.
if (entry.isIntersecting && window.pageYOffset !== lastScrollY.current && window.pageYOffset !== 0) {
updateTransform()
}
})
},
{
rootMargin,
threshold,
}
)
if (elementRef.current) {
observer.observe(elementRef.current)
}
// Set up scroll listener
const handleScroll = () => {
throttledUpdate()
}
window.addEventListener('scroll', handleScroll, { passive: true })
// Note: initial transform update is now gated to IntersectionObserver so updates occur
// only when the element is actually in view. CSS var is still initialized to 0px on mount.
return () => {
window.removeEventListener('scroll', handleScroll)
observer.disconnect()
if (rafRef.current) {
cancelAnimationFrame(rafRef.current)
}
}
}, [shouldAnimate, throttledUpdate, updateTransform, rootMargin, threshold])
// Return style object with transform
const style: React.CSSProperties = shouldAnimate
? {
transform: 'translateY(var(--parallax-offset, 0px))',
willChange: 'transform',
}
: {}
return {
ref: elementRef,
style,
}
}
/**
* Hook for multi-layer parallax effects
* Returns refs and styles for background, midground, and foreground layers
*/
export function useMultiLayerParallax(disabled = false) {
const background = useParallax({
depth: PARALLAX_CONFIG.depth.background,
disabled
})
const midground = useParallax({
depth: PARALLAX_CONFIG.depth.midground,
disabled
})
const foreground = useParallax({
depth: PARALLAX_CONFIG.depth.foreground,
disabled
})
return {
background,
midground,
foreground,
}
}
/**
* Hook for split-screen parallax effects
* Returns refs and styles for left and right panels
*/
export function useSplitScreenParallax(disabled = false) {
const leftPanel = useParallax({
depth: PARALLAX_CONFIG.layers.splitScreen.leftPanel.depth,
disabled
})
const rightPanel = useParallax({
depth: PARALLAX_CONFIG.layers.splitScreen.rightPanel.depth,
disabled
})
return {
leftPanel,
rightPanel,
}
}
/**
* Hook to detect reduced motion preference changes
*/
export function useReducedMotion() {
const [reducedMotion, setReducedMotion] = useState(prefersReducedMotion())
useEffect(() => {
const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)')
const handleChange = (e: MediaQueryListEvent) => {
setReducedMotion(e.matches)
}
mediaQuery.addEventListener('change', handleChange)
return () => {
mediaQuery.removeEventListener('change', handleChange)
}
}, [])
return reducedMotion
}