parallax /
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-calloutsWhere 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.