parallax /
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-scatterWhere 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