Innovations

Product Callouts (Floating Spec Sheet)

Central product image with floating spec cards around it. Each card parallax-drifts at its own rate — Apple-style spec page.

Preview

Source

tsx
"use client";

import { useEffect, useRef } from "react";

export interface CalloutItem {
  label: string;
  value: string;
  position:
    | "top-left"
    | "top-right"
    | "middle-left"
    | "middle-right"
    | "bottom-left"
    | "bottom-right";
  /** Parallax speed 0..1. Lower = lags further behind scroll. */
  speed?: number;
}

export interface ParallaxProductCalloutsProps {
  imageSrc?: string;
  eyebrow?: string;
  headline?: string;
  callouts?: CalloutItem[];
}

const POS_CLASS: Record<CalloutItem["position"], string> = {
  "top-left": "left-0 top-[8%] sm:-left-8",
  "top-right": "right-0 top-[14%] sm:-right-8",
  "middle-left": "left-0 top-1/2 -translate-y-1/2 sm:-left-12",
  "middle-right": "right-0 top-1/2 -translate-y-1/2 sm:-right-12",
  "bottom-left": "left-0 bottom-[10%] sm:-left-8",
  "bottom-right": "right-0 bottom-[6%] sm:-right-8",
};

const DEFAULT_CALLOUTS: CalloutItem[] = [
  { label: "Cut", value: "Single-thread, French seam", position: "top-left", speed: 0.7 },
  { label: "Fabric", value: "Organic Belgian linen, 220 gsm", position: "top-right", speed: 0.55 },
  { label: "Origin", value: "Made in Porto, Portugal", position: "middle-right", speed: 0.85 },
  { label: "Care", value: "Cold wash, hang dry", position: "bottom-left", speed: 0.6 },
  { label: "Warranty", value: "Repaired free for life", position: "bottom-right", speed: 0.75 },
];

export default function ParallaxProductCallouts({
  imageSrc = "/heroes/product-cutout/model.webp",
  eyebrow = "Parallax · Product Callouts",
  headline = "Floating spec sheet",
  callouts = DEFAULT_CALLOUTS,
}: ParallaxProductCalloutsProps) {
  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 = callouts[i]?.speed ?? 0.7;
          const offset = -top * (1 - speed);
          el.style.transform = `translate3d(0, ${offset}px, 0)`;
        });
      });
    };

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

  return (
    <section
      ref={sectionRef}
      className="relative w-full overflow-hidden bg-stone-950 py-24 text-white sm:py-32"
    >
      <div className="mx-auto max-w-2xl px-6 text-center">
        <p className="mb-4 text-xs font-semibold uppercase tracking-[0.3em] text-white/60">
          {eyebrow}
        </p>
        <h2 className="text-balance text-4xl font-semibold leading-[1.1] tracking-tight sm:text-5xl">
          {headline}
        </h2>
      </div>

      <div className="relative mx-auto mt-16 w-full max-w-4xl px-6 sm:mt-24">
        {/* Product image — stays put */}
        <div className="relative mx-auto aspect-[4/5] w-full max-w-md overflow-hidden rounded-2xl bg-stone-900 ring-1 ring-white/10 sm:max-w-lg">
          <img
            src={imageSrc}
            alt=""
            className="h-full w-full object-cover"
            loading="lazy"
            decoding="async"
          />
        </div>

        {/* Floating callouts — each on its own parallax track */}
        {callouts.map((c, i) => (
          <div
            key={c.label + i}
            ref={(el) => {
              refs.current[i] = el;
            }}
            className={`absolute z-10 w-44 will-change-transform sm:w-52 ${POS_CLASS[c.position]}`}
          >
            <div className="rounded-xl border border-white/15 bg-white/[0.07] p-4 backdrop-blur-md">
              <p className="text-[10px] font-semibold uppercase tracking-[0.2em] text-white/50">
                {c.label}
              </p>
              <p className="mt-1 text-sm font-medium leading-tight text-white">{c.value}</p>
            </div>
          </div>
        ))}
      </div>
    </section>
  );
}
Claude Code Instructions

CLI Install

npx innovations add product-callouts

Where to use it

Center photo + glass-card callouts at six anchor positions (top-left, top-right, middle-left, middle-right, bottom-left, bottom-right). Each card drifts at its own speed for subtle depth. CONFIGURE: - callouts: array of { label, value, position, speed }. Default ships with 5 fashion-product specs. - speed 0.5 = noticeable lag, 0.85 = subtle, 1.0 = pinned to scroll - position values: 'top-left' | 'top-right' | 'middle-left' | 'middle-right' | 'bottom-left' | 'bottom-right' - Skip positions you don't need by omitting them from the array WHEN TO USE: - Product detail / hero on ecom or hardware pages - Feature anatomy diagrams ('here's what's inside') - Pricing tier breakdowns where the tier is the central visual LIMITATIONS: - On mobile (below sm), callouts collapse to the photo edges. For very tall photos, the middle callouts can collide — use fewer or smaller cards on narrow viewports. - The connector lines from photo→callout (the Apple touch) are NOT included by default; add SVG <line> elements with absolute positioning if needed.