"use client";
import * as React from "react";
import {
animate,
motion,
useInView,
useMotionValue,
useMotionValueEvent,
useReducedMotion,
useTransform,
} from "motion/react";
import { ArrowRight, Home, Package, Star, Truck, Warehouse } from "lucide-react";
import { cn } from "@/lib/utils";
export interface ProductColor {
name: string;
/** Light and dark tones of the product finish (hex). */
from: string;
to: string;
/** Optional glow colour behind the product (defaults to `from`). */
glow?: string;
}
export interface ProductSpec {
value: number;
prefix?: string;
suffix?: string;
label: string;
/** 0–1, how full the little meter under the spec is. */
meter?: number;
}
export interface BentoProductProps {
eyebrow?: string;
heading?: string;
productName?: string;
tagline?: string;
price?: string;
ctaLabel?: string;
onCta?: () => void;
colors?: ProductColor[];
specs?: ProductSpec[];
rating?: number;
reviewCount?: string;
review?: { quote: string; author: string };
shipping?: { title: string; description: string; stops?: [string, string, string] };
className?: string;
}
const COLORS: ProductColor[] = [
{ name: "Graphite", from: "#52525b", to: "#18181b", glow: "#a1a1aa" },
{ name: "Sand", from: "#eadcc8", to: "#b39776" },
{ name: "Sage", from: "#b7c9b2", to: "#5d7a5a" },
{ name: "Cobalt", from: "#8aa2ff", to: "#2f45c6" },
];
const SPECS: ProductSpec[] = [
{ value: 40, suffix: "h", label: "Battery life", meter: 0.9 },
{ value: 42, prefix: "−", suffix: "dB", label: "Noise cancelling", meter: 0.78 },
{ value: 248, suffix: "g", label: "Featherweight", meter: 0.45 },
];
const EASE = [0.22, 1, 0.36, 1] as const;
export function BentoProduct({
eyebrow = "New release",
heading = "Meet the quietest thing you'll ever wear.",
productName = "Aria One",
tagline = "Over-ear headphones with adaptive noise cancelling and studio-grade sound.",
price = "$349",
ctaLabel = "Pre-order",
onCta,
colors = COLORS,
specs = SPECS,
rating = 4.9,
reviewCount = "2,184 reviews",
review = { quote: "The quietest commute I've ever had. I forget they're on.", author: "Hana K., verified buyer" },
shipping = {
title: "Free 2-day shipping",
description: "Carbon-neutral delivery and 60-day returns on every order.",
stops: ["Warehouse", "Local hub", "Your door"],
},
className,
}: BentoProductProps) {
const [colorIdx, setColorIdx] = React.useState(0);
const color = colors[colorIdx] ?? colors[0];
const uid = React.useId().replace(/[^a-zA-Z0-9_-]/g, "");
return (
<section className={cn("relative w-full overflow-x-clip bg-background py-12 sm:py-14", className)}>
<div className="mx-auto max-w-6xl px-4 sm:px-6">
<div className="mb-8 flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
<div>
<p className="text-sm font-medium text-primary">{eyebrow}</p>
<h2 className="mt-1 text-balance text-2xl font-semibold tracking-tight text-foreground sm:text-3xl">{heading}</h2>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4 lg:grid-rows-[208px_208px_auto]">
{/* Hero */}
<Tile index={0} className="md:col-span-2 lg:row-span-2">
<div className="relative flex h-full flex-col gap-6 p-6 sm:flex-row sm:items-center sm:p-7">
<motion.div
aria-hidden
className="absolute -right-20 top-1/2 size-80 -translate-y-1/2 rounded-full opacity-40 blur-3xl dark:opacity-30"
animate={{ backgroundColor: color.glow ?? color.from }}
transition={{ duration: 0.6 }}
/>
<div className="relative z-10 flex flex-1 flex-col sm:max-w-[48%]">
<span className="w-fit rounded-full border bg-background/70 px-2.5 py-0.5 text-[11px] font-medium text-muted-foreground backdrop-blur">
Ships October 14
</span>
<h3 className="mt-4 text-3xl font-semibold tracking-tight text-card-foreground sm:text-4xl">{productName}</h3>
<p className="mt-2 text-pretty text-sm text-muted-foreground">{tagline}</p>
<div className="mt-6 flex items-center gap-4">
<span className="text-2xl font-semibold tabular-nums text-card-foreground">{price}</span>
<button
type="button"
onClick={onCta}
className="group inline-flex items-center gap-1.5 rounded-full bg-primary px-4 py-2 text-sm font-medium text-primary-foreground shadow-md shadow-primary/20 outline-none transition hover:brightness-110 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-card"
>
{ctaLabel}
<ArrowRight className="size-4 transition-transform group-hover:translate-x-0.5" aria-hidden />
</button>
</div>
<p className="mt-3 text-xs text-muted-foreground">
Finish: <span className="font-medium text-foreground">{color.name}</span>
</p>
</div>
<div className="relative flex flex-1 items-center justify-center">
<Headphones color={color} uid={uid} />
</div>
</div>
</Tile>
{/* Specs */}
<Tile index={1} className="md:col-span-2">
<div className="flex h-full flex-col p-6">
<p className="text-sm font-semibold text-card-foreground">Built different</p>
<div className="mt-auto grid grid-cols-3 gap-4 pt-4">
{specs.map((s, i) => (
<Spec key={s.label} spec={s} delay={i * 0.12} />
))}
</div>
</div>
</Tile>
{/* Swatches */}
<Tile index={2}>
<div className="flex h-full flex-col p-6">
<p className="text-sm font-semibold text-card-foreground">Four finishes</p>
<p className="mt-1 text-xs text-muted-foreground">Anodised aluminium, vegan leather.</p>
<div role="radiogroup" aria-label="Colour" className="mt-auto flex flex-wrap gap-3 pt-4">
{colors.map((c, i) => {
const on = i === colorIdx;
return (
<button
key={c.name}
type="button"
role="radio"
aria-checked={on}
aria-label={c.name}
onClick={() => setColorIdx(i)}
className="relative size-10 rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-card"
>
{on && (
<motion.span
layoutId={`${uid}-swatch`}
className="absolute -inset-1 rounded-full border-2 border-foreground"
transition={{ type: "spring", stiffness: 500, damping: 35 }}
/>
)}
<span
className="absolute inset-0 rounded-full shadow-inner ring-1 ring-black/10 transition-transform hover:scale-105 dark:ring-white/15"
style={{ background: `linear-gradient(135deg, ${c.from}, ${c.to})` }}
/>
</button>
);
})}
</div>
</div>
</Tile>
{/* Reviews */}
<Tile index={3}>
<Reviews rating={rating} count={reviewCount} review={review} />
</Tile>
{/* Shipping */}
<Tile index={4} className="md:col-span-2 lg:col-span-4">
<Shipping {...shipping} />
</Tile>
</div>
</div>
</section>
);
}
function Tile({ index, className, children }: { index: number; className?: string; children: React.ReactNode }) {
const reduce = useReducedMotion();
return (
<motion.article
initial={{ opacity: 0, y: reduce ? 0 : 18, scale: reduce ? 1 : 0.98 }}
whileInView={{ opacity: 1, y: 0, scale: 1 }}
viewport={{ once: true, amount: 0.2 }}
transition={{ duration: 0.6, delay: index * 0.08, ease: EASE }}
className={cn("relative overflow-hidden rounded-3xl border bg-card shadow-sm", className)}
>
{children}
</motion.article>
);
}
/* ---------- Product illustration ---------- */
function Headphones({ color, uid }: { color: ProductColor; uid: string }) {
const reduce = useReducedMotion();
const g = `${uid}-cup`;
const band = `${uid}-band`;
return (
<div className="relative w-full max-w-[220px] sm:max-w-[280px]" aria-hidden>
<motion.div
animate={reduce ? undefined : { y: [0, -10, 0], rotate: [-1.5, 1.5, -1.5] }}
transition={{ repeat: Infinity, duration: 6, ease: "easeInOut" }}
>
<svg viewBox="0 0 240 220" className="w-full drop-shadow-2xl">
<defs>
<linearGradient id={g} x1="0" x2="1" y1="0" y2="1">
<motion.stop offset="0%" animate={{ stopColor: color.from }} transition={{ duration: 0.5 }} />
<motion.stop offset="100%" animate={{ stopColor: color.to }} transition={{ duration: 0.5 }} />
</linearGradient>
<linearGradient id={band} x1="0" x2="0" y1="0" y2="1">
<motion.stop offset="0%" animate={{ stopColor: color.to }} transition={{ duration: 0.5 }} />
<motion.stop offset="100%" animate={{ stopColor: color.from }} transition={{ duration: 0.5 }} />
</linearGradient>
</defs>
{/* headband */}
<path d="M46 128 C 44 36, 196 36, 194 128" fill="none" stroke={`url(#${band})`} strokeWidth="15" strokeLinecap="round" />
<path d="M58 116 C 60 58, 180 58, 182 116" fill="none" stroke="#000" strokeOpacity="0.22" strokeWidth="7" strokeLinecap="round" />
{/* sliders */}
<rect x="40" y="112" width="12" height="34" rx="4" fill="#a1a1aa" />
<rect x="188" y="112" width="12" height="34" rx="4" fill="#a1a1aa" />
{/* cups */}
{[
{ x: 22, cushion: 62 },
{ x: 172, cushion: 164 },
].map((c) => (
<g key={c.x}>
<rect x={c.cushion} y="128" width="14" height="72" rx="7" fill="#27272a" />
<rect x={c.x} y="122" width="46" height="84" rx="23" fill={`url(#${g})`} />
<rect x={c.x + 8} y="132" width="10" height="44" rx="5" fill="#fff" opacity="0.28" />
<circle cx={c.x + 23} cy="190" r="2.5" fill="#fff" opacity="0.5" />
</g>
))}
</svg>
</motion.div>
<motion.div
className="mx-auto mt-2 h-4 w-2/3 rounded-[50%] bg-foreground/15 blur-md"
animate={reduce ? undefined : { scaleX: [1, 0.85, 1], opacity: [0.8, 0.5, 0.8] }}
transition={{ repeat: Infinity, duration: 6, ease: "easeInOut" }}
/>
</div>
);
}
/* ---------- Specs ---------- */
function Spec({ spec, delay }: { spec: ProductSpec; delay: number }) {
const ref = React.useRef<HTMLDivElement>(null);
const inView = useInView(ref, { once: true, amount: 0.6 });
const reduce = useReducedMotion();
const mv = useMotionValue(0);
const text = useTransform(mv, (v) => Math.round(v).toString());
React.useEffect(() => {
if (!inView) return;
if (reduce) {
mv.set(spec.value);
return;
}
const c = animate(mv, spec.value, { duration: 1.6, delay, ease: [0.16, 1, 0.3, 1] });
return () => c.stop();
}, [inView, spec.value, delay, reduce, mv]);
return (
<div ref={ref} className="min-w-0">
<p className="whitespace-nowrap text-2xl font-semibold tabular-nums tracking-tight text-card-foreground sm:text-4xl">
{spec.prefix}
<motion.span>{text}</motion.span>
<span className="text-base text-muted-foreground sm:text-xl">{spec.suffix}</span>
</p>
<p className="mt-1 truncate text-xs text-muted-foreground">{spec.label}</p>
<div className="mt-3 h-1 overflow-hidden rounded-full bg-muted">
<motion.div
className="h-full rounded-full bg-gradient-to-r from-primary to-fuchsia-500"
initial={{ width: 0 }}
animate={{ width: inView ? `${(spec.meter ?? 1) * 100}%` : 0 }}
transition={{ duration: 1.4, delay: delay + 0.2, ease: EASE }}
/>
</div>
</div>
);
}
/* ---------- Reviews ---------- */
function Reviews({ rating, count, review }: { rating: number; count: string; review: { quote: string; author: string } }) {
const ref = React.useRef<HTMLDivElement>(null);
const inView = useInView(ref, { once: true, amount: 0.6 });
return (
<div ref={ref} className="flex h-full flex-col p-6">
<div className="flex items-baseline gap-2">
<span className="text-3xl font-semibold tabular-nums tracking-tight text-card-foreground">{rating.toFixed(1)}</span>
<span className="text-xs text-muted-foreground">{count}</span>
</div>
<div className="mt-2 flex gap-1" role="img" aria-label={`Rated ${rating} out of 5`}>
{[0, 1, 2, 3, 4].map((i) => {
const fill = Math.max(0, Math.min(1, rating - i));
return (
<motion.span
key={i}
className="relative"
initial={{ scale: 0.4, opacity: 0 }}
animate={inView ? { scale: 1, opacity: 1 } : undefined}
transition={{ delay: 0.2 + i * 0.1, type: "spring", stiffness: 500, damping: 18 }}
>
<Star className="size-5 text-muted-foreground/30" fill="currentColor" strokeWidth={0} />
<span className="absolute inset-0 overflow-hidden" style={{ width: `${fill * 100}%` }}>
<Star className="size-5 text-amber-400" fill="currentColor" strokeWidth={0} />
</span>
</motion.span>
);
})}
</div>
<blockquote className="mt-auto pt-3">
<p className="text-pretty text-sm text-card-foreground">“{review.quote}”</p>
<footer className="mt-1.5 text-xs text-muted-foreground">{review.author}</footer>
</blockquote>
</div>
);
}
/* ---------- Shipping route ---------- */
const ROUTE = "M 30 70 C 150 70, 170 26, 290 30 S 470 92, 560 66 S 700 34, 770 44";
function Shipping({ title, description, stops = ["Warehouse", "Local hub", "Your door"] }: { title: string; description: string; stops?: [string, string, string] }) {
const wrap = React.useRef<HTMLDivElement>(null);
const pathRef = React.useRef<SVGPathElement>(null);
const truckRef = React.useRef<HTMLDivElement>(null);
const hubRef = React.useRef<HTMLDivElement>(null);
const inView = useInView(wrap, { amount: 0.4 });
const reduce = useReducedMotion();
const t = useMotionValue(0);
const [stage, setStage] = React.useState(0);
const place = React.useCallback((v: number) => {
const p = pathRef.current;
const truck = truckRef.current;
if (!p || !truck) return;
const pt = p.getPointAtLength(p.getTotalLength() * v);
truck.style.left = `${(pt.x / 800) * 100}%`;
truck.style.top = `${(pt.y / 100) * 100}%`;
}, []);
useMotionValueEvent(t, "change", (v) => {
place(v);
const s = v >= 0.98 ? 2 : v >= 0.5 ? 1 : 0;
setStage((prev) => (prev === s ? prev : s));
});
React.useEffect(() => {
// put the middle stop exactly half-way along the route
const p = pathRef.current;
const hub = hubRef.current;
if (p && hub) {
const mid = p.getPointAtLength(p.getTotalLength() / 2);
hub.style.left = `${(mid.x / 800) * 100}%`;
hub.style.top = `${mid.y}%`;
}
place(t.get());
if (!inView) return;
if (reduce) {
t.set(1);
return;
}
const c = animate(t, [0, 1], { duration: 5.5, ease: "easeInOut", repeat: Infinity, repeatDelay: 1.4 });
return () => c.stop();
}, [inView, reduce, t, place]);
const icons = [Warehouse, Package, Home];
const pos = [
{ x: 30, y: 70 },
{ x: 427, y: 58 },
{ x: 770, y: 44 },
];
return (
<div ref={wrap} className="flex h-full flex-col gap-4 p-6 md:flex-row md:items-center md:gap-8">
<div className="shrink-0 md:w-56">
<p className="flex items-center gap-2 text-sm font-semibold text-card-foreground">
<Truck className="size-4 text-primary" aria-hidden /> {title}
</p>
<p className="mt-1 text-pretty text-xs text-muted-foreground">{description}</p>
</div>
<div className="relative h-24 min-w-0 flex-1" aria-hidden>
<svg viewBox="0 0 800 100" preserveAspectRatio="none" className="absolute inset-0 size-full overflow-visible">
<path ref={pathRef} d={ROUTE} fill="none" stroke="var(--border)" strokeWidth="3" strokeDasharray="2 8" strokeLinecap="round" />
<motion.path d={ROUTE} fill="none" stroke="var(--primary)" strokeWidth="3" strokeLinecap="round" style={{ pathLength: t }} />
</svg>
{pos.map((p, i) => {
const Icon = icons[i];
const reached = i <= stage;
return (
<div
key={i}
ref={i === 1 ? hubRef : undefined}
className="absolute flex -translate-x-1/2 -translate-y-1/2 flex-col items-center"
style={{ left: `${(p.x / 800) * 100}%`, top: `${(p.y / 100) * 100}%` }}
>
<span
className={cn(
"flex size-7 items-center justify-center rounded-full border-2 transition-colors duration-300",
reached ? "border-primary bg-primary text-primary-foreground" : "border-border bg-card text-muted-foreground",
)}
>
<Icon className="size-3.5" />
</span>
<span className="absolute top-full mt-1 whitespace-nowrap text-[10px] font-medium text-muted-foreground">{stops[i]}</span>
</div>
);
})}
<div ref={truckRef} className="absolute z-10 -translate-x-1/2 -translate-y-[130%]" style={{ left: "3.75%", top: "70%" }}>
<span className="flex size-8 items-center justify-center rounded-xl bg-foreground text-background shadow-lg">
<Truck className="size-4" />
</span>
</div>
</div>
</div>
);
}