parallax /
Scroll-driven translateY
Scroll listener (rAF-throttled, IntersectionObserver-gated) drives translate3d on the background. Works on iOS. The modern default.
Preview
Source
tsx
"use client";
import { useEffect, useRef } from "react";
export interface ParallaxTranslateYProps {
imageSrc?: string;
/** How much slower the image moves than the page. 0 = totally pinned, 1 = scrolls with page. */
speed?: number;
eyebrow?: string;
headline?: string;
subhead?: string;
}
export default function ParallaxTranslateY({
imageSrc = "/heroes/crouchingtiger/bg.webp",
speed = 0.5,
eyebrow = "Parallax · 02",
headline = "Transform translateY on scroll",
subhead =
"A scroll listener (rAF-throttled) drives translate3d on the image. Works everywhere including iOS Safari. The 'classic' modern parallax.",
}: ParallaxTranslateYProps) {
const sectionRef = useRef<HTMLElement | null>(null);
const imgRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
let raf = 0;
let visible = false;
const io = new IntersectionObserver(
(entries) => {
visible = entries[0]?.isIntersecting ?? false;
},
{ threshold: 0 }
);
if (sectionRef.current) io.observe(sectionRef.current);
const onScroll = () => {
if (!visible || !sectionRef.current || !imgRef.current) return;
cancelAnimationFrame(raf);
raf = requestAnimationFrame(() => {
const rect = sectionRef.current!.getBoundingClientRect();
// distance the section top has scrolled past the viewport top
const offset = -rect.top * (1 - speed);
imgRef.current!.style.transform = `translate3d(0, ${offset}px, 0)`;
});
};
onScroll();
window.addEventListener("scroll", onScroll, { passive: true });
return () => {
io.disconnect();
window.removeEventListener("scroll", onScroll);
cancelAnimationFrame(raf);
};
}, [speed]);
return (
<section
ref={sectionRef}
className="relative isolate flex min-h-[120svh] w-full items-center justify-center overflow-hidden bg-slate-900"
>
<div
ref={imgRef}
className="absolute inset-x-0 top-0 -z-10 h-[150%] will-change-transform"
style={{
backgroundImage: `url(${imageSrc})`,
backgroundSize: "cover",
backgroundPosition: "center",
backgroundRepeat: "no-repeat",
}}
/>
<div className="absolute inset-0 -z-0 bg-gradient-to-b from-black/40 via-black/20 to-black/70" />
<div className="relative z-10 mx-auto max-w-3xl px-6 text-center text-white">
<p className="mb-4 text-xs font-semibold uppercase tracking-[0.3em] text-white/70">
{eyebrow}
</p>
<h1 className="text-balance text-4xl font-semibold leading-[1.1] tracking-tight sm:text-6xl">
{headline}
</h1>
<p className="mx-auto mt-6 max-w-xl text-base leading-relaxed text-white/80 sm:text-lg">
{subhead}
</p>
</div>
</section>
);
} Claude Code Instructions
CLI Install
npx innovations add translate-yWhere to use it
The modern, cross-browser default for parallax. Works on iOS Safari (which classic-fixed does not).
Tune <ParallaxTranslateY speed={0.5} /> — 0 pins the image, 1 disables the effect, 0.4–0.6 is the comfortable range. Above 1.0 the image scrolls FASTER than the page (rare, but interesting for foreground elements).
The component is IntersectionObserver-gated, so the scroll handler is inert when the section is off-screen — no perf cost while user reads the rest of the page.
PERFORMANCE:
- Uses translate3d + will-change: transform → GPU layer
- requestAnimationFrame batches scroll updates
- Image element is 150% the section height so it has room to move
ACCESSIBILITY: respect reduced-motion by passing speed={1} (no parallax) when matchMedia('(prefers-reduced-motion: reduce)').matches — the component does not do this automatically, by design (you may want subtle motion regardless).