Fazekit

Code

"use client";
import * as React from "react";
import {
  AnimatePresence,
  motion,
  useMotionValue,
  useReducedMotion,
  useSpring,
  useTime,
  useTransform,
  type MotionValue,
} from "motion/react";
import { Check, Heart, RotateCcw, ShieldCheck, ShoppingBag, Snowflake, Star, Truck } from "lucide-react";
import { cn } from "@/lib/utils";

export interface ProductColor {
  name: string;
  /** Main body colour (hex). */
  base: string;
  /** Shade used on the bottle edges (hex). */
  shade: string;
  /** Colour of the label text / cap accent (hex). */
  ink: string;
}

export interface HeroSplitProductProps {
  badge?: string;
  name?: string;
  description?: string;
  price?: number;
  compareAt?: number;
  currency?: string;
  rating?: number;
  reviews?: number;
  colors?: ProductColor[];
  sizes?: string[];
  /** Short labels that orbit the product. */
  features?: string[];
  brand?: string;
  onAddToCart?: (sel: { color: string; size: string }) => void;
  className?: string;
}

const EASE = [0.22, 1, 0.36, 1] as const;

const DEFAULT_COLORS: ProductColor[] = [
  { name: "Glacier", base: "#7cc4e8", shade: "#2f6f96", ink: "#0c3550" },
  { name: "Ember", base: "#f28b5b", shade: "#a8401d", ink: "#4a1706" },
  { name: "Moss", base: "#8fb275", shade: "#46663a", ink: "#1f3317" },
  { name: "Onyx", base: "#3b3b42", shade: "#141418", ink: "#e9e9ee" },
];

export function HeroSplitProduct({
  badge = "New · Spring drop",
  name = "Lumen Flask Pro",
  description = "Double-wall vacuum steel keeps drinks ice-cold for 24 hours and hot for 12. Leak-proof cap, powder-coat grip, and a shape that slips into any bag.",
  price = 48,
  compareAt = 64,
  currency = "USD",
  rating = 4.9,
  reviews = 2184,
  colors = DEFAULT_COLORS,
  sizes = ["500 ml", "750 ml", "1 L"],
  features = ["24h ice-cold", "BPA-free steel", "Leak-proof cap", "Lifetime warranty"],
  brand = "LUMEN",
  onAddToCart,
  className,
}: HeroSplitProductProps) {
  const reduce = useReducedMotion();
  const uid = React.useId();
  const [colorIdx, setColorIdx] = React.useState(0);
  const [size, setSize] = React.useState(sizes[Math.min(1, sizes.length - 1)] ?? "");
  const [added, setAdded] = React.useState(false);
  const [liked, setLiked] = React.useState(false);
  const color = colors[colorIdx] ?? DEFAULT_COLORS[0];
  const fmt = React.useMemo(() => new Intl.NumberFormat("en-US", { style: "currency", currency }), [currency]);

  React.useEffect(() => {
    if (!added) return;
    const t = setTimeout(() => setAdded(false), 2200);
    return () => clearTimeout(t);
  }, [added]);

  const fade = (d: number) => ({
    initial: reduce ? { opacity: 0 } : { opacity: 0, y: 16 },
    animate: { opacity: 1, y: 0 },
    transition: { duration: 0.7, delay: d, ease: EASE },
  });

  return (
    <section className={cn("relative w-full overflow-hidden bg-background text-foreground", className)}>
      <div className="mx-auto grid max-w-6xl items-center gap-10 px-5 py-14 sm:px-8 lg:grid-cols-[1fr_1.1fr] lg:gap-6 lg:py-20">
        {/* Copy */}
        <div className="order-2 lg:order-1">
          <motion.span {...fade(0)} className="inline-flex items-center gap-1.5 rounded-full border bg-card px-3 py-1 text-xs font-medium text-muted-foreground">
            <span className="size-1.5 rounded-full" style={{ background: color.base }} />
            {badge}
          </motion.span>
          <motion.h1 {...fade(0.05)} className="mt-5 text-4xl font-semibold tracking-tight sm:text-5xl lg:text-6xl">
            {name}
          </motion.h1>
          <motion.div {...fade(0.1)} className="mt-4 flex items-center gap-2 text-sm">
            <div className="flex" aria-hidden>
              {[0, 1, 2, 3, 4].map((i) => (
                <Star key={i} className={cn("size-4", i < Math.round(rating) ? "fill-amber-400 text-amber-400" : "text-muted-foreground/40")} />
              ))}
            </div>
            <span className="font-medium">{rating.toFixed(1)}</span>
            <span className="text-muted-foreground">({reviews.toLocaleString("en-US")} reviews)</span>
          </motion.div>
          <motion.p {...fade(0.15)} className="mt-5 max-w-md text-pretty leading-relaxed text-muted-foreground">
            {description}
          </motion.p>

          <motion.div {...fade(0.2)} className="mt-6 flex items-baseline gap-3">
            <span className="text-3xl font-semibold tabular-nums">{fmt.format(price)}</span>
            {compareAt && compareAt > price && (
              <>
                <span className="text-lg tabular-nums text-muted-foreground line-through">{fmt.format(compareAt)}</span>
                <span className="rounded-md bg-emerald-500/12 px-2 py-0.5 text-xs font-semibold text-emerald-700 dark:text-emerald-400">
                  Save {Math.round((1 - price / compareAt) * 100)}%
                </span>
              </>
            )}
          </motion.div>

          <motion.div {...fade(0.25)} className="mt-7 space-y-5">
            <fieldset>
              <legend className="text-sm font-medium">
                Color <span className="font-normal text-muted-foreground">— {color.name}</span>
              </legend>
              <div role="radiogroup" aria-label="Color" className="mt-2.5 flex gap-2.5">
                {colors.map((c, i) => (
                  <button
                    key={c.name}
                    type="button"
                    role="radio"
                    aria-checked={i === colorIdx}
                    aria-label={c.name}
                    onClick={() => setColorIdx(i)}
                    className={cn(
                      "relative size-9 rounded-full ring-offset-2 ring-offset-background transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
                      i === colorIdx ? "ring-2 ring-foreground" : "ring-1 ring-border hover:ring-foreground/40",
                    )}
                    style={{ background: `linear-gradient(135deg, ${c.base}, ${c.shade})` }}
                  />
                ))}
              </div>
            </fieldset>
            {sizes.length > 0 && (
              <fieldset>
                <legend className="text-sm font-medium">Size</legend>
                <div role="radiogroup" aria-label="Size" className="mt-2.5 inline-flex rounded-xl border bg-muted/50 p-1">
                  {sizes.map((s) => (
                    <button
                      key={s}
                      type="button"
                      role="radio"
                      aria-checked={s === size}
                      onClick={() => setSize(s)}
                      className="relative rounded-lg px-4 py-1.5 text-sm font-medium transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
                    >
                      {s === size && (
                        <motion.span layoutId={`${uid}-size`} className="absolute inset-0 rounded-lg bg-background shadow-sm" transition={{ type: "spring", bounce: 0.2, duration: 0.45 }} />
                      )}
                      <span className={cn("relative", s === size ? "text-foreground" : "text-muted-foreground")}>{s}</span>
                    </button>
                  ))}
                </div>
              </fieldset>
            )}
          </motion.div>

          <motion.div {...fade(0.3)} className="mt-8 flex gap-3">
            <button
              type="button"
              onClick={() => {
                setAdded(true);
                onAddToCart?.({ color: color.name, size });
              }}
              className="relative inline-flex h-12 flex-1 items-center justify-center overflow-hidden rounded-xl bg-foreground px-6 text-sm font-semibold text-background transition hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background sm:max-w-xs"
            >
              <AnimatePresence mode="wait" initial={false}>
                <motion.span
                  key={added ? "added" : "add"}
                  initial={{ y: 18, opacity: 0 }}
                  animate={{ y: 0, opacity: 1 }}
                  exit={{ y: -18, opacity: 0 }}
                  transition={{ duration: 0.22 }}
                  className="inline-flex items-center gap-2"
                >
                  {added ? <Check className="size-4" aria-hidden /> : <ShoppingBag className="size-4" aria-hidden />}
                  {added ? "Added to cart" : `Add to cart · ${fmt.format(price)}`}
                </motion.span>
              </AnimatePresence>
              <span className="sr-only" aria-live="polite">{added ? `${name} added to cart` : ""}</span>
            </button>
            <button
              type="button"
              aria-pressed={liked}
              aria-label={liked ? "Remove from wishlist" : "Add to wishlist"}
              onClick={() => setLiked((v) => !v)}
              className="grid size-12 shrink-0 place-items-center rounded-xl border bg-card transition hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
            >
              <motion.span animate={liked && !reduce ? { scale: [1, 1.35, 1] } : { scale: 1 }} transition={{ duration: 0.35 }}>
                <Heart className={cn("size-5", liked ? "fill-rose-500 text-rose-500" : "text-muted-foreground")} />
              </motion.span>
            </button>
          </motion.div>

          <motion.ul {...fade(0.35)} className="mt-8 flex flex-wrap gap-x-6 gap-y-3 text-sm text-muted-foreground">
            {[
              { icon: Truck, t: "Free shipping" },
              { icon: RotateCcw, t: "30-day returns" },
              { icon: ShieldCheck, t: "Lifetime warranty" },
            ].map(({ icon: Icon, t }) => (
              <li key={t} className="flex items-center gap-2">
                <Icon className="size-4 shrink-0 text-foreground/70" aria-hidden />
                {t}
              </li>
            ))}
          </motion.ul>
        </div>

        {/* Visual */}
        <ProductStage color={color} features={features} brand={brand} size={size} reduce={!!reduce} />
      </div>
    </section>
  );
}

function ProductStage({ color, features, brand, size, reduce }: { color: ProductColor; features: string[]; brand: string; size: string; reduce: boolean }) {
  const ref = React.useRef<HTMLDivElement>(null);
  const [rx, setRx] = React.useState(200);
  const [compact, setCompact] = React.useState(false);
  const shown = compact ? features.slice(0, 3) : features;
  const mx = useMotionValue(0);
  const my = useMotionValue(0);
  const sx = useSpring(mx, { stiffness: 120, damping: 18 });
  const sy = useSpring(my, { stiffness: 120, damping: 18 });
  const bottleX = useTransform(sx, (v) => v * 26);
  const bottleY = useTransform(sy, (v) => v * 18);
  const bottleR = useTransform(sx, (v) => v * 7);
  const discX = useTransform(sx, (v) => v * -34);
  const discY = useTransform(sy, (v) => v * -24);
  const ringX = useTransform(sx, (v) => v * 48);
  const ringY = useTransform(sy, (v) => v * 30);

  React.useEffect(() => {
    const el = ref.current;
    if (!el) return;
    const ro = new ResizeObserver(([e]) => {
      const w = e.contentRect.width;
      const small = w < 480;
      setCompact(small);
      setRx(small ? w / 2 - 60 : Math.min(230, w / 2 - 78));
    });
    ro.observe(el);
    return () => ro.disconnect();
  }, []);

  return (
    <motion.div
      ref={ref}
      initial={{ opacity: 0, scale: 0.94 }}
      animate={{ opacity: 1, scale: 1 }}
      transition={{ duration: 0.9, ease: EASE }}
      onPointerMove={(e) => {
        if (reduce) return;
        const r = e.currentTarget.getBoundingClientRect();
        mx.set((e.clientX - r.left) / r.width - 0.5);
        my.set((e.clientY - r.top) / r.height - 0.5);
      }}
      onPointerLeave={() => (mx.set(0), my.set(0))}
      className="relative order-1 h-[400px] w-full select-none sm:h-[500px] lg:order-2 lg:h-[560px]"
      aria-hidden
    >
      {/* Backdrop disc */}
      <motion.div style={{ x: discX, y: discY }} className="absolute inset-0 grid place-items-center">
        <motion.div
          animate={{ background: `radial-gradient(circle at 35% 30%, ${color.base}cc, ${color.shade}55 55%, transparent 72%)` }}
          transition={{ duration: 0.6 }}
          className="size-[300px] rounded-full sm:size-[420px]"
        />
      </motion.div>
      <div className="absolute inset-0 grid place-items-center">
        <div className="size-[340px] rounded-full border border-dashed border-foreground/10 sm:size-[480px]" />
      </div>

      {/* Orbit: back half */}
      <motion.div style={{ x: ringX, y: ringY }} className="absolute inset-0">
        {shown.map((f, i) => (
          <OrbitChip key={f} label={f} index={i} count={shown.length} rx={rx} ry={compact ? 0.55 : 0.32} compact={compact} layer="back" reduce={reduce} />
        ))}
      </motion.div>

      {/* Bottle */}
      <motion.div style={{ x: bottleX, y: bottleY, rotate: bottleR }} className="absolute inset-0 grid place-items-center">
        <motion.div
          animate={reduce ? undefined : { y: [0, -12, 0] }}
          transition={{ duration: 5, repeat: Infinity, ease: "easeInOut" }}
          className="relative"
        >
          <Bottle color={color} brand={brand} size={size} />
        </motion.div>
      </motion.div>

      {/* Orbit: front half */}
      <motion.div style={{ x: ringX, y: ringY }} className="absolute inset-0">
        {shown.map((f, i) => (
          <OrbitChip key={f} label={f} index={i} count={shown.length} rx={rx} ry={compact ? 0.55 : 0.32} compact={compact} layer="front" reduce={reduce} />
        ))}
      </motion.div>
    </motion.div>
  );
}

const ORBIT_PERIOD = 16000;

function OrbitChip({
  label,
  index,
  count,
  rx,
  ry,
  compact,
  layer,
  reduce,
}: {
  label: string;
  index: number;
  count: number;
  rx: number;
  ry: number;
  compact: boolean;
  layer: "front" | "back";
  reduce: boolean;
}) {
  const time = useTime();
  const base = (index / count) * Math.PI * 2 + 0.4;
  const angle: MotionValue<number> = useTransform(time, (t) => base + (reduce ? 0 : (t / ORBIT_PERIOD) * Math.PI * 2));
  const x = useTransform(angle, (a) => Math.cos(a) * rx);
  const y = useTransform(angle, (a) => Math.sin(a) * rx * ry);
  const depth = useTransform(angle, (a) => Math.sin(a)); // -1 back … 1 front
  const scale = useTransform(depth, (d) => 0.86 + (d + 1) * 0.08);
  const opacity = useTransform(depth, (d) => {
    const inLayer = layer === "front" ? d >= 0 : d < 0;
    if (!inLayer) return 0;
    return layer === "front" ? 1 : 0.55 + (d + 1) * 0.35;
  });

  return (
    <motion.div style={{ x, y, scale, opacity }} className="absolute left-1/2 top-[52%] -translate-x-1/2 -translate-y-1/2">
      <div className={cn("whitespace-nowrap rounded-full border border-foreground/10 bg-background/75 font-medium shadow-lg shadow-black/5 backdrop-blur-md", compact ? "px-2.5 py-1.5 text-[11px]" : "px-3.5 py-2 text-xs")}>
        <span className="inline-flex items-center gap-1.5">
          <Snowflake className="size-3.5 text-sky-500" />
          {label}
        </span>
      </div>
    </motion.div>
  );
}

function Bottle({ color, brand, size }: { color: ProductColor; brand: string; size: string }) {
  const id = React.useId().replace(/:/g, "");
  return (
    <svg viewBox="0 0 200 470" className="h-[320px] w-auto drop-shadow-2xl sm:h-[400px] lg:h-[440px]">
      <defs>
        <linearGradient id={`${id}-body`} x1="0" x2="1">
          <motion.stop offset="0%" animate={{ stopColor: color.shade }} transition={{ duration: 0.5 }} />
          <motion.stop offset="30%" animate={{ stopColor: color.base }} transition={{ duration: 0.5 }} />
          <motion.stop offset="55%" animate={{ stopColor: color.base }} transition={{ duration: 0.5 }} />
          <motion.stop offset="100%" animate={{ stopColor: color.shade }} transition={{ duration: 0.5 }} />
        </linearGradient>
        <linearGradient id={`${id}-cap`} x1="0" x2="1">
          <stop offset="0%" stopColor="#1c1c21" />
          <stop offset="40%" stopColor="#4a4a52" />
          <stop offset="100%" stopColor="#141418" />
        </linearGradient>
        <linearGradient id={`${id}-steel`} x1="0" x2="1">
          <stop offset="0%" stopColor="#8a8d94" />
          <stop offset="45%" stopColor="#eef0f3" />
          <stop offset="100%" stopColor="#7a7d84" />
        </linearGradient>
        <radialGradient id={`${id}-shadow`}>
          <stop offset="0%" stopColor="#000" stopOpacity="0.35" />
          <stop offset="100%" stopColor="#000" stopOpacity="0" />
        </radialGradient>
      </defs>
      <ellipse cx="100" cy="458" rx="70" ry="9" fill={`url(#${id}-shadow)`} />
      {/* cap */}
      <rect x="66" y="8" width="68" height="62" rx="12" fill={`url(#${id}-cap)`} />
      {[20, 30, 40, 50].map((y) => (
        <rect key={y} x="70" y={y} width="60" height="2" rx="1" fill="#fff" opacity="0.07" />
      ))}
      <path d="M86 8h28v-2a4 4 0 0 0-4-4H90a4 4 0 0 0-4 4z" fill="#2a2a30" />
      {/* steel neck */}
      <rect x="74" y="68" width="52" height="20" rx="3" fill={`url(#${id}-steel)`} />
      {/* body */}
      <path
        d="M76 86 C76 108 38 112 38 150 L38 414 Q38 446 72 446 L128 446 Q162 446 162 414 L162 150 C162 112 124 108 124 86 Z"
        fill={`url(#${id}-body)`}
      />
      {/* highlights */}
      <path d="M56 150 C56 128 70 118 80 112 L80 118 C72 124 62 134 62 152 L62 410 Q62 424 56 426 Z" fill="#fff" opacity="0.28" />
      <rect x="138" y="150" width="6" height="260" rx="3" fill="#fff" opacity="0.1" />
      {/* label */}
      <motion.text
        x="100"
        y="282"
        textAnchor="middle"
        fontSize="22"
        fontWeight="800"
        letterSpacing="6"
        animate={{ fill: color.ink }}
        transition={{ duration: 0.5 }}
        style={{ fontFamily: "inherit" }}
      >
        {brand}
      </motion.text>
      <motion.text
        x="100"
        y="302"
        textAnchor="middle"
        fontSize="8"
        letterSpacing="1.6"
        animate={{ fill: color.ink }}
        transition={{ duration: 0.5 }}
        opacity="0.7"
        style={{ fontFamily: "inherit" }}
      >
        INSULATED · {size.toUpperCase()}
      </motion.text>
      <rect x="38" y="404" width="124" height="3" fill="#000" opacity="0.08" />
    </svg>
  );
}

More in Heroes

View all →