Innovations

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-y

Where 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).