Innovations

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

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