Website Sections
ProductShowcase
One product stage beside a list of features. Each feature changes the view and moves a marker to the part it talks about. The tour stops once someone picks.
Download
- React
- Tailwind CSS v4
- motion
components/ui/product-showcase.tsx
"use client";
import { useEffect, useId, useRef, useState, type CSSProperties, type ElementType, type KeyboardEvent, type ReactNode } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
export type ProductShowcaseFeature = {
title: string;
text: string;
/** Fills the stage. An image with object-cover works well. */
media: ReactNode;
/** Where the marker sits on the stage, in percent. Leave out for no marker. */
spot?: { x: number; y: number };
};
type ProductShowcaseProps = {
features: ProductShowcaseFeature[];
title?: string;
/** Small line above the title. */
eyebrow?: string;
subtitle?: string;
/** Seconds on each feature. 0 turns the auto tour off. */
interval?: number;
/** Colour of the marker and the active feature. */
color?: string;
/** Stage colour. */
tile?: string;
/** Put the stage on the right. */
reverse?: boolean;
/** h2 by default. */
headingAs?: ElementType;
/** Set padding and colours here. */
className?: string;
};
/* ProductShowcase: one product stage beside a list of features. Each feature changes the view and moves a marker to the part it talks about. The tour runs by itself until someone picks a feature; it waits on hover, on focus and off screen. */
export function ProductShowcase({
features,
title,
eyebrow,
subtitle,
interval = 6,
color = "#a78bfa",
tile = "#15122a",
reverse = false,
headingAs: Heading = "h2",
className = "bg-[#0b0a14] px-5 py-16 text-white",
}: ProductShowcaseProps) {
const reduce = useReducedMotion();
const id = useId();
const box = useRef<HTMLElement>(null);
const bar = useRef<HTMLSpanElement>(null);
const tabs = useRef<(HTMLButtonElement | null)[]>([]);
const timer = useRef<Animation | null>(null);
const held = useRef({ hover: false, focus: false, away: true });
const [active, setActive] = useState(0);
const [touring, setTouring] = useState(true);
const tour = touring && interval > 0 && !reduce;
const count = features.length;
const spot = features[active].spot;
const hold = (key: "hover" | "focus" | "away", on: boolean) => {
held.current[key] = on;
const { hover, focus, away } = held.current;
if (hover || focus || away) timer.current?.pause();
else timer.current?.play();
};
useEffect(() => {
const line = bar.current;
if (!tour || !line) return;
const run = line.animate([{ transform: "scaleX(0)" }, { transform: "scaleX(1)" }], { duration: interval * 1000, fill: "forwards" });
run.onfinish = () => setActive((now) => (now + 1) % count);
timer.current = run;
const { hover, focus, away } = held.current;
if (hover || focus || away) run.pause();
return () => {
run.cancel();
timer.current = null;
};
}, [active, count, interval, tour]);
useEffect(() => {
const section = box.current;
if (!section) return;
const onScreen = new IntersectionObserver(
([entry]) => {
held.current.away = !entry.isIntersecting;
const { hover, focus, away } = held.current;
if (hover || focus || away) timer.current?.pause();
else timer.current?.play();
},
{ threshold: 0.2 },
);
onScreen.observe(section);
return () => onScreen.disconnect();
}, []);
const pick = (i: number) => {
setTouring(false);
setActive(i);
};
const keys = (e: KeyboardEvent, i: number) => {
const step = { ArrowDown: 1, ArrowRight: 1, ArrowUp: -1, ArrowLeft: -1 }[e.key];
const to = step ? (i + step + count) % count : e.key === "Home" ? 0 : e.key === "End" ? count - 1 : -1;
if (to < 0) return;
e.preventDefault();
pick(to);
tabs.current[to]?.focus();
};
return (
<section
ref={box}
aria-labelledby={title ? id : undefined}
onPointerEnter={(e) => e.pointerType === "mouse" && hold("hover", true)}
onPointerLeave={() => hold("hover", false)}
onFocus={() => hold("focus", true)}
onBlur={() => hold("focus", false)}
className={`@container ${className}`}
style={{ "--ps-color": color } as CSSProperties}
>
{title && (
<header className="mx-auto mb-8 max-w-2xl text-center">
{eyebrow && <p className="mb-2 text-xs font-medium tracking-[0.2em] uppercase opacity-70">{eyebrow}</p>}
<Heading id={id} className="text-[clamp(1.5rem,6cqw,2.5rem)] leading-tight font-semibold tracking-tight text-balance">
{title}
</Heading>
{subtitle && <p className="mt-3 text-pretty opacity-70">{subtitle}</p>}
</header>
)}
<div className="mx-auto grid max-w-6xl items-center gap-5 @3xl:grid-cols-[minmax(0,3fr)_minmax(0,2fr)] @3xl:gap-10">
<div
id={`${id}-panel`}
role="tabpanel"
aria-labelledby={`${id}-tab-${active}`}
className={`relative aspect-[16/10] overflow-hidden rounded-2xl border border-current/10 ${reverse ? "@3xl:order-2" : ""}`}
style={{ backgroundColor: tile }}
>
<AnimatePresence initial={false}>
<motion.div
key={active}
className="absolute inset-0 *:size-full"
initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 1.05 }}
animate={reduce ? { opacity: 1 } : { opacity: 1, scale: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: reduce ? 0 : 0.6, ease: [0.16, 1, 0.3, 1] }}
>
{features[active].media}
</motion.div>
</AnimatePresence>
{spot && (
<motion.span
aria-hidden
className="absolute -mt-3 -ml-3 grid size-6 place-items-center"
initial={false}
animate={{ left: `${spot.x}%`, top: `${spot.y}%` }}
transition={reduce ? { duration: 0 } : { type: "spring", stiffness: 110, damping: 17 }}
>
<span className="absolute inset-0 animate-ping rounded-full bg-[var(--ps-color)] opacity-50 motion-reduce:hidden" />
<span className="size-3 rounded-full border-2 border-white bg-[var(--ps-color)] shadow" />
</motion.span>
)}
</div>
<div role="tablist" aria-orientation="vertical" aria-label={title ?? "Features"} className="grid gap-1">
{features.map((feature, i) => {
const on = i === active;
return (
<button
key={feature.title}
ref={(el) => {
tabs.current[i] = el;
}}
id={`${id}-tab-${i}`}
type="button"
role="tab"
aria-selected={on}
aria-controls={`${id}-panel`}
tabIndex={on ? 0 : -1}
onClick={() => pick(i)}
onKeyDown={(e) => keys(e, i)}
className="relative overflow-hidden rounded-xl px-4 py-3 text-left transition-colors hover:bg-current/5"
>
<span className={`block font-semibold transition-opacity ${on ? "" : "opacity-60"}`}>{feature.title}</span>
<span className="grid transition-[grid-template-rows] duration-300 motion-reduce:transition-none" style={{ gridTemplateRows: on ? "1fr" : "0fr" }}>
<span className="min-h-0 overflow-hidden">
<span className="block pt-1 text-sm text-pretty opacity-70">{feature.text}</span>
</span>
</span>
{on && (
<span aria-hidden className="absolute inset-x-4 bottom-0 h-0.5 overflow-hidden rounded-full bg-current/10">
<span ref={bar} className={`block h-full origin-left bg-[var(--ps-color)] ${tour ? "" : "hidden"}`} />
</span>
)}
</button>
);
})}
</div>
</div>
</section>
);
}
1. Install the dependency.
npm i motion2. Paste the Code tab into
components/ui/product-showcase.tsx. The styles are Tailwind CSS v4 classes.3. Use it. This is the preview above:
app/product-showcase-demo.tsx
import { ProductShowcase } from "@/components/ui/product-showcase"; const FEATURES = [ { title: "Coastal mode", text: "A calm theme that follows the light in your room.", media: <img src="/demo/tide.jpg" alt="Sea and hills in soft blue light" className="object-cover" />, spot: { x: 28, y: 36 }, }, { title: "Warm exports", text: "Print-ready colour, checked before it leaves the app.", media: <img src="/demo/ember.jpg" alt="Red hills under a burning sky" className="object-cover" />, spot: { x: 68, y: 30 }, }, { title: "Field view", text: "Sharp detail, even on a small screen outdoors.", media: <img src="/demo/moss.jpg" alt="Green hills in soft light" className="object-cover" />, spot: { x: 50, y: 70 }, }, ]; export default function ProductShowcaseExample() { return ( <div className="h-full overflow-y-auto"> <ProductShowcase title="One product, three moods" features={FEATURES} headingAs="h3" className="min-h-full bg-[#0b0a14] p-4 text-white" /> </div> ); }
Props
| Name | Type | Default | What it does |
|---|---|---|---|
| features | ProductShowcaseFeature[] | โ | Each has title, text and media, plus optional spot: { x, y } in percent. |
| title | string | โ | Heading above. |
| eyebrow | string | โ | Small line above the title. |
| subtitle | string | โ | Line under the title. |
| interval | number | 6 | Seconds on each feature. 0 turns the auto tour off. |
| color | string | "#a78bfa" | Colour of the marker and the active feature. |
| tile | string | "#15122a" | Stage colour. |
| reverse | boolean | false | Put the stage on the right. |
| headingAs | ElementType | "h2" | Heading tag. |
| className | string | "bg-[#0b0a14] px-5 py-16 text-white" | Set padding and colours here. |