Fazekit

Code

"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">&ldquo;{review.quote}&rdquo;</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>
  );
}

More in Bento Grids

View all →