Innovations

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

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