Innovations

Polaroid Scatter

Five tilted polaroid-framed photos scatter across the section. Each translates at its own speed — closer ones drift fast, deeper ones barely move.

Preview

Source

tsx
"use client";

import { useEffect, useRef } from "react";

export interface PolaroidPiece {
  src: string;
  caption?: string;
  /** Tailwind position classes, applied to the polaroid wrapper. */
  position: string;
  /** Rotation in degrees. */
  rotate: number;
  /** Parallax speed coefficient. 0 = pinned, 1 = scrolls with page. Lower = farther/slower. */
  speed: number;
  /** Polaroid width — Tailwind utility (e.g. "w-44 sm:w-56"). */
  size?: string;
}

export interface ParallaxPolaroidScatterProps {
  eyebrow?: string;
  headline?: string;
  subhead?: string;
  polaroids?: PolaroidPiece[];
}

const DEFAULT_POLAROIDS: PolaroidPiece[] = [
  {
    src: "/oz-beach.jpg",
    caption: "Whitsundays · '23",
    position: "left-[2%] top-[8%]",
    rotate: -8,
    speed: 0.25,
    size: "w-36 sm:w-44",
  },
  {
    src: "/heroes/landscape-card-bg.webp",
    caption: "Cascade Range",
    position: "right-[5%] top-[6%]",
    rotate: 6,
    speed: 0.55,
    size: "w-44 sm:w-56",
  },
  {
    src: "/heroes/crouchingtiger/bg.webp",
    caption: "Sage Hill",
    position: "left-[10%] bottom-[12%]",
    rotate: -4,
    speed: 0.4,
    size: "w-40 sm:w-52",
  },
  {
    src: "/heroes/event-countdown/woman.webp",
    caption: "Studio · A",
    position: "right-[10%] bottom-[8%]",
    rotate: 7,
    speed: 0.7,
    size: "w-40 sm:w-48",
  },
  {
    src: "/heroes/event-countdown/man.webp",
    caption: "Studio · B",
    position: "left-1/2 -translate-x-1/2 bottom-[2%]",
    rotate: -2,
    speed: 0.5,
    size: "w-36 sm:w-44",
  },
];

export default function ParallaxPolaroidScatter({
  eyebrow = "Parallax · Polaroid Scatter",
  headline = "Photos at five different depths",
  subhead =
    "Each polaroid translates upward at a different speed. The fastest cards feel closer; the slowest feel deeper into the room.",
  polaroids = DEFAULT_POLAROIDS,
}: ParallaxPolaroidScatterProps) {
  const sectionRef = useRef<HTMLElement | null>(null);
  const refs = useRef<(HTMLDivElement | 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) return;
      cancelAnimationFrame(raf);
      raf = requestAnimationFrame(() => {
        const top = sectionRef.current!.getBoundingClientRect().top;
        refs.current.forEach((el, i) => {
          if (!el) return;
          const speed = polaroids[i]?.speed ?? 0.4;
          const offset = -top * (1 - speed);
          el.style.transform = `translate3d(0, ${offset}px, 0) rotate(${polaroids[i]!.rotate}deg)`;
        });
      });
    };

    onScroll();
    window.addEventListener("scroll", onScroll, { passive: true });
    return () => {
      io.disconnect();
      window.removeEventListener("scroll", onScroll);
      cancelAnimationFrame(raf);
    };
  }, [polaroids]);

  return (
    <section
      ref={sectionRef}
      className="relative isolate min-h-[130svh] w-full overflow-hidden bg-stone-100 py-24"
    >
      {/* Centered headline — anchor for the scatter */}
      <div className="relative z-10 mx-auto max-w-2xl px-6 text-center">
        <p className="mb-4 text-xs font-semibold uppercase tracking-[0.3em] text-stone-500">
          {eyebrow}
        </p>
        <h2 className="text-balance font-serif text-4xl italic leading-[1.1] tracking-tight text-stone-900 sm:text-5xl">
          {headline}
        </h2>
        <p className="mx-auto mt-5 max-w-lg text-base leading-relaxed text-stone-600 sm:text-lg">
          {subhead}
        </p>
      </div>

      {/* Polaroids — absolutely positioned, each on its own parallax track */}
      {polaroids.map((p, i) => (
        <div
          key={p.src + i}
          ref={(el) => {
            refs.current[i] = el;
          }}
          className={`absolute will-change-transform ${p.position} ${p.size ?? "w-44"}`}
          style={{ transform: `rotate(${p.rotate}deg)` }}
        >
          <div className="bg-white p-2 pb-10 shadow-[0_10px_30px_-10px_rgba(0,0,0,0.25),0_4px_8px_-4px_rgba(0,0,0,0.1)]">
            <div className="aspect-[4/5] w-full overflow-hidden bg-stone-200">
              <img
                src={p.src}
                alt={p.caption ?? ""}
                className="h-full w-full object-cover"
                loading="lazy"
                decoding="async"
              />
            </div>
            {p.caption && (
              <p className="mt-2 text-center font-mono text-[10px] uppercase tracking-widest text-stone-500">
                {p.caption}
              </p>
            )}
          </div>
        </div>
      ))}
    </section>
  );
}
Claude Code Instructions

CLI Install

npx innovations add polaroid-scatter

Where to use it

Five polaroids in absolute positions, each with its own rotate + parallax speed. Closer photos (higher speed) read as foreground; slower ones recede. EACH POLAROID has: - src — the image - caption — small text under the photo - position — Tailwind position classes (left-[2%] top-[8%], etc.) - rotate — degrees (-10 to +10 reads natural) - speed — 0 to 1; 0.7+ feels close, 0.3- feels deep - size — width Tailwind utility (w-36 sm:w-44 is the base) WHEN TO USE: - 'About us' or origin-story sections - Travel / lifestyle hero or break - Yearbook / community pages TIPS: - Don't overlap polaroids more than 30% — overlap makes the parallax feel chaotic instead of layered - Keep rotations between ±10° unless you want a more chaotic 'scrapbook' feel - The headline stays put (non-parallax) so visitors have a stable anchor while the photos drift