parallax /
Two-Column Lag
Long-form text scrolls normally in the left column; a sticky image in the right column parallax-lags. The image stays present while the words run past.
Preview
Source
tsx
"use client";
import { useEffect, useRef } from "react";
export interface ParallaxTwoColumnLagProps {
imageSrc?: string;
eyebrow?: string;
headline?: string;
panels?: { title: string; body: string }[];
}
const DEFAULT_PANELS = [
{
title: "Light shapes the room",
body:
"Every space starts with the question of where the sun rises. Floor plans bend toward the morning. Window seats appear without being designed.",
},
{
title: "Materials carry the season",
body:
"Oak in winter, linen in summer, terracotta when the floor needs to remember the heat. The body recognizes a season-aware room before the eye does.",
},
{
title: "Negative space is the point",
body:
"The most expensive thing on the room schedule is what isn't there. A walled-off wing. A breathing corner. A two-meter approach that doesn't sell you anything.",
},
{
title: "Patience compounds",
body:
"A house with three more years of patience reads as a different house. Furniture migrates. Wood darkens. Tile chips in the places people stand. None of this can be rushed.",
},
];
export default function ParallaxTwoColumnLag({
imageSrc = "/heroes/wilson/docs.webp",
eyebrow = "Parallax · Two-Column Lag",
headline = "The image lags the words",
panels = DEFAULT_PANELS,
}: ParallaxTwoColumnLagProps) {
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();
// Image moves at 0.5x scroll speed in section coords — lags the text
const offset = -rect.top * 0.45;
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);
};
}, []);
return (
<section
ref={sectionRef}
className="relative w-full overflow-hidden bg-white py-20 sm:py-28"
>
<div className="mx-auto grid max-w-7xl gap-12 px-6 lg:grid-cols-2 lg:gap-16">
{/* Left column — text panels, scroll normally */}
<div className="space-y-20 lg:space-y-32">
<div>
<p className="mb-3 text-xs font-semibold uppercase tracking-[0.3em] text-stone-500">
{eyebrow}
</p>
<h2 className="text-balance text-4xl font-semibold leading-[1.1] tracking-tight text-stone-900 sm:text-5xl">
{headline}
</h2>
</div>
{panels.map((p, i) => (
<div key={i} className="max-w-lg">
<p className="mb-2 font-mono text-xs uppercase tracking-widest text-stone-400">
§{String(i + 1).padStart(2, "0")}
</p>
<h3 className="text-2xl font-semibold leading-tight text-stone-900 sm:text-3xl">
{p.title}
</h3>
<p className="mt-4 text-base leading-relaxed text-stone-600">{p.body}</p>
</div>
))}
</div>
{/* Right column — image, parallax-lagged */}
<div className="relative h-full">
<div className="sticky top-0 h-screen overflow-hidden rounded-2xl bg-stone-100">
<div
ref={imgRef}
className="absolute inset-x-0 -top-[20%] h-[150%] will-change-transform"
style={{
backgroundImage: `url(${imageSrc})`,
backgroundSize: "cover",
backgroundPosition: "center",
}}
/>
</div>
</div>
</div>
</section>
);
} Claude Code Instructions
CLI Install
npx innovations add two-column-lagWhere to use it
Two columns. Left = stacked text panels (normal scroll). Right = sticky image container; the image element inside translates Y at 0.45x scroll speed, creating a subtle drift.
The 'sticky' on the image wrapper does the heavy lifting — the image pins to viewport while text scrolls past. The Y-parallax adds the lagging-photo feeling on top.
WHEN TO USE:
- Long-form storytelling pages (about, manifesto, brand book)
- Editorial articles where a hero image needs to stay present without being repeated
- Pricing pages with one big visual + multiple panels of detail
LIMITATIONS:
- Below lg: lays out single-column; image moves to its natural place between panels. The parallax still works.
- The image area is one viewport tall — for very long text, the sticky image just stays present until scroll passes the section. To swap images mid-section, layer multiple of these back-to-back.