parallax /
Floating Content Cards
Inline content cards drift upward at different rates as the section passes the viewport. Subtle depth without taking over the page.
Preview
Source
tsx
"use client";
import { useEffect, useRef } from "react";
export interface ParallaxFloatingCardsProps {
eyebrow?: string;
headline?: string;
subhead?: string;
cards?: { src: string; title: string; caption: string }[];
}
const DEFAULT_CARDS = [
{
src: "/heroes/landscape-card-bg.webp",
title: "Frame the long view",
caption: "Wide horizons and quiet light. Used for premium hospitality and outdoor brands.",
},
{
src: "/heroes/crouchingtiger/bg.webp",
title: "Make it tactile",
caption: "Mid-tone palettes that ground digital experiences in something physical.",
},
{
src: "/heroes/blob-portrait/woman.webp",
title: "Lead with people",
caption: "Editorial portraits paired with serif type — magazine voice, modern container.",
},
];
export default function ParallaxFloatingCards({
eyebrow = "Parallax · Floating Cards",
headline = "Cards that drift",
subhead =
"Each card translates upward at its own speed as you scroll past. Subtle depth — the kind you feel before you notice it.",
cards = DEFAULT_CARDS,
}: ParallaxFloatingCardsProps) {
const sectionRef = useRef<HTMLElement | null>(null);
const cardRefs = 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;
const winH = window.innerHeight;
// -top is how far we've scrolled past section start
const past = -top;
cardRefs.current.forEach((el, i) => {
if (!el) return;
// Each card lags by a different amount — middle moves least.
const lagFactor = [0.18, 0.06, 0.24][i] ?? 0.15;
const offset = past * lagFactor;
el.style.transform = `translate3d(0, ${-offset}px, 0)`;
});
});
};
onScroll();
window.addEventListener("scroll", onScroll, { passive: true });
return () => {
io.disconnect();
window.removeEventListener("scroll", onScroll);
cancelAnimationFrame(raf);
};
}, [cards.length]);
return (
<section
ref={sectionRef}
className="relative w-full bg-stone-50 py-24 sm:py-32 lg:py-40"
>
<div className="mx-auto max-w-6xl px-6">
<div className="mx-auto max-w-2xl text-center">
<p className="mb-4 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>
<p className="mx-auto mt-5 max-w-xl text-base leading-relaxed text-stone-600 sm:text-lg">
{subhead}
</p>
</div>
<div className="mt-16 grid gap-6 sm:grid-cols-2 lg:mt-24 lg:grid-cols-3 lg:gap-8">
{cards.map((c, i) => (
<div
key={c.src + i}
ref={(el) => {
cardRefs.current[i] = el;
}}
className={`group will-change-transform ${i === 1 ? "lg:translate-y-12" : ""}`}
>
<div className="overflow-hidden rounded-2xl bg-stone-200 shadow-sm ring-1 ring-stone-200/80">
<img
src={c.src}
alt=""
className="aspect-[4/5] w-full object-cover transition-transform duration-700 group-hover:scale-[1.03]"
loading="lazy"
decoding="async"
/>
</div>
<h3 className="mt-5 text-lg font-semibold text-stone-900">{c.title}</h3>
<p className="mt-2 text-sm leading-relaxed text-stone-600">{c.caption}</p>
</div>
))}
</div>
</div>
</section>
);
} Claude Code Instructions
CLI Install
npx innovations add floating-cardsWhere to use it
A normal 3-card feature row, but each card translates Y at a slightly different speed as the section scrolls past. Reads as 'alive' without being a parallax hero.
The middle card is also offset down by lg:translate-y-12 (a static stagger) so the three already form a soft Z. The parallax lag exaggerates that on scroll.
WHEN TO USE:
- Feature / value-prop sections on marketing pages
- Anywhere a 3-card grid feels too static
- Pairs naturally with a Cmd-press of pseudo-3D photography (portraits, products, environments)
TUNING:
- lagFactor array [0.18, 0.06, 0.24] inside the component — values are how far each card lags scroll. 0 = pinned to scroll, 0.3 = noticeable drift.
- Negative values reverse the drift direction (cards sink instead of rise).
- IntersectionObserver-gated so the scroll handler costs nothing off-screen.