Implementing Parallax Scroll Effects on a Website
Parallax is different speed movement of layers during scroll, creating an illusion of depth. Implemented three ways: CSS background-attachment: fixed (limited), JavaScript with requestAnimationFrame (universal), or GSAP ScrollTrigger scrub (smoothest). CSS approach breaks on iOS Safari due to how mobile browser optimizes scroll rendering — JavaScript is almost always needed.
CSS Parallax (Desktop Only)
/* Background images only, doesn't work on iOS Safari */
.parallax-section {
background-image: url('/hero-bg.jpg');
background-attachment: fixed;
background-size: cover;
background-position: center;
min-height: 100vh;
}
/* Alternative via transform with will-change */
.parallax-layer {
will-change: transform;
transform: translateZ(0); /* force GPU acceleration */
}
JavaScript: Optimized Parallax
Key performance rules:
- Read
scrollYonly fromscrollevent (orIntersectionObserverfor visibility detection) - Write to DOM only via
requestAnimationFrame - Use
transforminstead oftop/left— doesn't cause reflow - Add
will-change: transformbefore starting animation
// hooks/useParallax.ts
import { useEffect, useRef, useState } from 'react'
interface ParallaxOptions {
speed?: number // 0 = no movement, 1 = with scroll, -1 = reverse
direction?: 'vertical' | 'horizontal'
disabled?: boolean // for mobile
}
export function useParallax({
speed = 0.5,
direction = 'vertical',
disabled = false,
}: ParallaxOptions = {}) {
const elementRef = useRef<HTMLElement>(null)
const rafRef = useRef<number | null>(null)
const lastScrollY = useRef(0)
useEffect(() => {
if (disabled || !elementRef.current) return
const el = elementRef.current
el.style.willChange = 'transform'
let ticking = false
const updateTransform = () => {
const rect = el.getBoundingClientRect()
const viewportCenter = window.innerHeight / 2
const elementCenter = rect.top + rect.height / 2
const distanceFromCenter = elementCenter - viewportCenter
const offset = -distanceFromCenter * (speed - 1)
if (direction === 'vertical') {
el.style.transform = `translateY(${offset}px)`
} else {
el.style.transform = `translateX(${offset}px)`
}
ticking = false
}
const onScroll = () => {
if (!ticking) {
rafRef.current = requestAnimationFrame(updateTransform)
ticking = true
}
}
window.addEventListener('scroll', onScroll, { passive: true })
updateTransform() // initial position
return () => {
window.removeEventListener('scroll', onScroll)
if (rafRef.current) cancelAnimationFrame(rafRef.current)
el.style.willChange = ''
el.style.transform = ''
}
}, [speed, direction, disabled])
return elementRef
}
// components/ParallaxImage.tsx
import { useParallax } from '../hooks/useParallax'
interface ParallaxImageProps {
src: string
alt: string
speed?: number
className?: string
}
export function ParallaxImage({ src, alt, speed = 0.6, className }: ParallaxImageProps) {
// Disable parallax on mobile — save resources
const isMobile = typeof window !== 'undefined'
? window.matchMedia('(max-width: 768px)').matches
: false
const ref = useParallax({ speed, disabled: isMobile })
return (
<div className="overflow-hidden">
<img
ref={ref as React.RefObject<HTMLImageElement>}
src={src}
alt={alt}
className={className}
// Slightly enlarge image to hide edges during parallax
style={{ transform: 'scale(1.1)', transformOrigin: 'center' }}
/>
</div>
)
}
GSAP ScrollTrigger Scrub (Recommended)
GSAP handles parallax via scrub — binds animation to scroll position with optional smoothing:
// components/ParallaxSection.tsx
import { useEffect, useRef } from 'react'
import { gsap } from 'gsap'
import { ScrollTrigger } from 'gsap/ScrollTrigger'
gsap.registerPlugin(ScrollTrigger)
export function ParallaxSection() {
const sectionRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const ctx = gsap.context(() => {
// Layer 1: slow background
gsap.to('.bg-layer', {
yPercent: -20,
ease: 'none',
scrollTrigger: {
trigger: sectionRef.current,
start: 'top bottom',
end: 'bottom top',
scrub: true,
},
})
// Layer 2: mid-ground
gsap.to('.mid-layer', {
yPercent: -40,
ease: 'none',
scrollTrigger: {
trigger: sectionRef.current,
start: 'top bottom',
end: 'bottom top',
scrub: 1.5, // smoothing delay in seconds
},
})
// Layer 3: foreground (fastest)
gsap.to('.fg-layer', {
yPercent: -60,
ease: 'none',
scrollTrigger: {
trigger: sectionRef.current,
start: 'top bottom',
end: 'bottom top',
scrub: 2,
},
})
// Horizontal parallax for decorative elements
gsap.to('.float-left', {
x: -50,
ease: 'none',
scrollTrigger: {
trigger: sectionRef.current,
start: 'top bottom',
end: 'bottom top',
scrub: true,
},
})
gsap.to('.float-right', {
x: 50,
ease: 'none',
scrollTrigger: {
trigger: sectionRef.current,
start: 'top bottom',
end: 'bottom top',
scrub: true,
},
})
}, sectionRef)
return () => ctx.revert()
}, [])
return (
<div ref={sectionRef} className="relative h-screen overflow-hidden">
<div className="bg-layer absolute inset-0 bg-cover bg-center"
style={{ backgroundImage: "url('/bg-mountains.jpg')", height: '120%', top: '-10%' }}
/>
<div className="mid-layer absolute inset-0 flex items-center justify-center">
<h2 className="text-6xl font-bold text-white">Title</h2>
</div>
<div className="fg-layer absolute bottom-0 w-full">
<svg viewBox="0 0 1440 200">{/* clouds, trees */}</svg>
</div>
</div>
)
}
Mouse Parallax (3D Card Tilt)
// components/TiltCard.tsx
import { useRef, MouseEvent } from 'react'
import { motion, useMotionValue, useSpring, useTransform } from 'framer-motion'
export function TiltCard({ children }: { children: React.ReactNode }) {
const cardRef = useRef<HTMLDivElement>(null)
const mouseX = useMotionValue(0)
const mouseY = useMotionValue(0)
const rotateX = useSpring(useTransform(mouseY, [-0.5, 0.5], [10, -10]), {
stiffness: 200, damping: 20
})
const rotateY = useSpring(useTransform(mouseX, [-0.5, 0.5], [-10, 10]), {
stiffness: 200, damping: 20
})
const handleMouseMove = (e: MouseEvent) => {
const rect = cardRef.current!.getBoundingClientRect()
const x = (e.clientX - rect.left) / rect.width - 0.5
const y = (e.clientY - rect.top) / rect.height - 0.5
mouseX.set(x)
mouseY.set(y)
}
const handleMouseLeave = () => {
mouseX.set(0)
mouseY.set(0)
}
return (
<motion.div
ref={cardRef}
style={{ rotateX, rotateY, transformStyle: 'preserve-3d' }}
onMouseMove={handleMouseMove}
onMouseLeave={handleMouseLeave}
className="cursor-pointer"
>
{children}
</motion.div>
)
}
Respect prefers-reduced-motion
// hooks/useReducedMotion.ts
import { useEffect, useState } from 'react'
export function useReducedMotion(): boolean {
const [reducedMotion, setReducedMotion] = useState(false)
useEffect(() => {
const mq = window.matchMedia('(prefers-reduced-motion: reduce)')
setReducedMotion(mq.matches)
const handler = (e: MediaQueryListEvent) => setReducedMotion(e.matches)
mq.addEventListener('change', handler)
return () => mq.removeEventListener('change', handler)
}, [])
return reducedMotion
}
Typical Timelines
CSS parallax for one hero section — 3–4 hours. JS/GSAP parallax for 3–5 sections with multiple layers — 2–3 working days. Full scene with mouse, 3D tilt, mobile fallback, and tests — 3–5 days.







