Product

Product 01 — Product Detail

Classic product-page split: thumbnail gallery with a CSS-only product mock, star rating, price with compare-at, color and size selectors, quantity stepper, add-to-cart and buy-now, and a trust row.

$ npx shadcn@latest add @ludusvibe/product-01
Open in v0

Code

Installed to components/sections/product-01.tsx — it's yours to edit.

"use client"

import * as React from "react"
import { motion } from "motion/react"
import {
  Heart,
  Minus,
  Plus,
  RotateCcw,
  ShieldCheck,
  ShoppingBag,
  Star,
  Truck,
} from "lucide-react"

import { Badge } from "@/components/ui/badge"
import { buttonVariants } from "@/components/ui/button"
import { fadeUp, stagger, viewport } from "@/lib/motion"
import { cn } from "@/lib/utils"

type ProductImage = { src: string; alt: string }

type ProductColor = { name: string; className?: string }

export type Product01Props = {
  brand?: string
  title?: string
  price?: number
  compareAt?: number
  currency?: string
  rating?: number
  reviewCount?: number
  description?: string
  /** Selectable colors. Pass strings for names only, or objects to control the swatch fill. */
  colors?: (ProductColor | string)[]
  sizes?: string[]
  /** Real product photos. When present, the gallery swaps the CSS mock for <img>. */
  images?: ProductImage[]
  badge?: string
  addToCartLabel?: string
  buyNowLabel?: string
  /** Heading element — use "h1" when this is the page's main heading. */
  as?: "h1" | "h2"
  className?: string
}

const defaultColors: ProductColor[] = [
  { name: "Graphite", className: "bg-gradient-to-br from-foreground/80 to-foreground/40" },
  { name: "Sand", className: "bg-gradient-to-br from-primary/40 via-muted to-background" },
  { name: "Ocean", className: "bg-gradient-to-br from-primary/70 to-primary/20" },
  { name: "Clay", className: "bg-gradient-to-br from-secondary via-muted to-secondary/50" },
]

const defaultSizes = ["XS", "S", "M", "L", "XL"]

function toColor(color: ProductColor | string): ProductColor {
  return typeof color === "string" ? { name: color } : color
}

const swatchFallbacks = [
  "bg-gradient-to-br from-foreground/80 to-foreground/40",
  "bg-gradient-to-br from-primary/60 to-primary/20",
  "bg-gradient-to-br from-secondary via-muted to-secondary/50",
  "bg-gradient-to-br from-primary/40 via-muted to-background",
]

export function Product01({
  brand = "Atelier North",
  title = "Merino Wool Overshirt",
  price = 148,
  compareAt = 210,
  currency = "$",
  rating = 4.6,
  reviewCount = 214,
  description = "A midweight overshirt spun from responsibly sourced merino — breathable enough to layer indoors, structured enough to wear as a jacket. Cut for a relaxed, everyday fit.",
  colors = defaultColors,
  sizes = defaultSizes,
  images,
  badge = "New",
  addToCartLabel = "Add to cart",
  buyNowLabel = "Buy it now",
  as: Heading = "h2",
  className,
}: Product01Props) {
  const resolvedColors = colors.map(toColor)

  const [activeIndex, setActiveIndex] = React.useState(0)
  const [selectedColor, setSelectedColor] = React.useState(0)
  const [selectedSize, setSelectedSize] = React.useState(
    sizes.length > 2 ? 2 : 0
  )
  const [qty, setQty] = React.useState(1)

  const filledStars = Math.round(rating)
  const hasImages = images && images.length > 0
  const activeImage = hasImages
    ? images[Math.min(activeIndex, images.length - 1)]
    : undefined

  const savePercent =
    compareAt && compareAt > price
      ? Math.round(((compareAt - price) / compareAt) * 100)
      : 0

  const thumbCount = hasImages ? images.length : 4

  return (
    <section className={cn("bg-background px-6 py-20 md:py-28", className)}>
      <motion.div
        initial="hidden"
        whileInView="visible"
        viewport={viewport}
        variants={stagger(0.08)}
        className="mx-auto grid max-w-6xl grid-cols-1 items-start gap-10 lg:grid-cols-2 lg:gap-16"
      >
        {/* LEFT — gallery */}
        <motion.div variants={fadeUp} className="flex flex-col gap-4">
          <div className="relative aspect-square overflow-hidden rounded-2xl border bg-card">
            {badge && (
              <Badge className="absolute top-4 left-4 z-10">{badge}</Badge>
            )}
            {activeImage ? (
              <img
                src={activeImage.src}
                alt={activeImage.alt}
                className="size-full object-cover"
              />
            ) : (
              <ProductMock index={activeIndex} />
            )}
          </div>

          <div className="grid grid-cols-4 gap-3">
            {Array.from({ length: thumbCount }).map((_, i) => {
              const img = hasImages ? images[i] : undefined
              const isActive = activeIndex === i
              return (
                <button
                  key={i}
                  type="button"
                  onClick={() => setActiveIndex(i)}
                  aria-label={
                    img ? `View ${img.alt}` : `View product angle ${i + 1}`
                  }
                  aria-pressed={isActive}
                  className={cn(
                    "relative aspect-square overflow-hidden rounded-lg border bg-card transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring",
                    isActive
                      ? "ring-2 ring-primary"
                      : "hover:border-primary/40"
                  )}
                >
                  {img ? (
                    <img
                      src={img.src}
                      alt={img.alt}
                      className="size-full object-cover"
                    />
                  ) : (
                    <ProductMock index={i} thumb />
                  )}
                </button>
              )
            })}
          </div>
        </motion.div>

        {/* RIGHT — info */}
        <motion.div variants={fadeUp} className="flex flex-col">
          <p className="text-xs font-medium tracking-widest text-muted-foreground uppercase">
            {brand}
          </p>

          <Heading className="mt-2 font-heading text-3xl font-semibold tracking-tight text-balance md:text-4xl">
            {title}
          </Heading>

          {/* Rating */}
          <div className="mt-3 flex flex-wrap items-center gap-2 text-sm">
            <span className="flex items-center" aria-hidden>
              {Array.from({ length: 5 }).map((_, i) => (
                <Star
                  key={i}
                  className={cn(
                    "size-4",
                    i < filledStars
                      ? "fill-current text-primary"
                      : "text-muted-foreground/30"
                  )}
                />
              ))}
            </span>
            <span className="text-muted-foreground tabular-nums">
              {rating.toFixed(1)}
            </span>
            <span className="text-muted-foreground/50" aria-hidden>
              ·
            </span>
            <a
              href="#reviews"
              className="text-muted-foreground underline-offset-4 transition-colors hover:text-foreground hover:underline"
            >
              {reviewCount} reviews
            </a>
          </div>

          {/* Price */}
          <div className="mt-5 flex flex-wrap items-center gap-3">
            <span className="text-2xl font-semibold tabular-nums">
              {currency}
              {price}
            </span>
            {compareAt && compareAt > price && (
              <span className="text-muted-foreground line-through tabular-nums">
                {currency}
                {compareAt}
              </span>
            )}
            {savePercent > 0 && (
              <span className="rounded-full bg-primary/10 px-2.5 py-1 text-xs font-medium text-primary">
                Save {savePercent}%
              </span>
            )}
          </div>

          <p className="mt-5 max-w-prose text-sm leading-relaxed text-muted-foreground">
            {description}
          </p>

          {/* Color selector */}
          <div className="mt-8">
            <div className="flex items-baseline justify-between">
              <span className="text-sm font-medium">Color</span>
              <span className="text-sm text-muted-foreground">
                {resolvedColors[selectedColor]?.name}
              </span>
            </div>
            <div className="mt-3 flex flex-wrap gap-3">
              {resolvedColors.map((color, i) => {
                const isSelected = selectedColor === i
                return (
                  <button
                    key={color.name}
                    type="button"
                    onClick={() => setSelectedColor(i)}
                    aria-label={`Select color ${color.name}`}
                    aria-pressed={isSelected}
                    className={cn(
                      "size-8 rounded-full border ring-offset-2 ring-offset-background transition-transform outline-none focus-visible:ring-2 focus-visible:ring-ring",
                      color.className ??
                        swatchFallbacks[i % swatchFallbacks.length],
                      isSelected
                        ? "ring-2 ring-primary"
                        : "hover:scale-105"
                    )}
                  />
                )
              })}
            </div>
          </div>

          {/* Size selector */}
          <div className="mt-6">
            <div className="flex items-baseline justify-between">
              <span className="text-sm font-medium">Size</span>
              <a
                href="#size-guide"
                className="text-sm text-muted-foreground underline-offset-4 transition-colors hover:text-foreground hover:underline"
              >
                Size guide
              </a>
            </div>
            <div className="mt-3 flex flex-wrap gap-2">
              {sizes.map((size, i) => {
                const isSelected = selectedSize === i
                return (
                  <button
                    key={size}
                    type="button"
                    onClick={() => setSelectedSize(i)}
                    aria-pressed={isSelected}
                    className={cn(
                      "min-w-11 rounded-md border px-3 py-2 text-sm font-medium transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring",
                      isSelected
                        ? "border-primary bg-primary text-primary-foreground"
                        : "text-foreground hover:border-primary/40"
                    )}
                  >
                    {size}
                  </button>
                )
              })}
            </div>
          </div>

          {/* Quantity + actions */}
          <div className="mt-8 flex flex-col gap-4">
            <div className="flex items-center gap-4">
              <div className="flex items-center rounded-md border">
                <button
                  type="button"
                  onClick={() => setQty((q) => Math.max(1, q - 1))}
                  aria-label="Decrease quantity"
                  disabled={qty <= 1}
                  className="flex size-10 items-center justify-center rounded-l-md text-muted-foreground transition-colors outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-40"
                >
                  <Minus className="size-4" />
                </button>
                <span
                  className="w-10 text-center text-sm font-medium tabular-nums"
                  aria-live="polite"
                >
                  {qty}
                </span>
                <button
                  type="button"
                  onClick={() => setQty((q) => q + 1)}
                  aria-label="Increase quantity"
                  className="flex size-10 items-center justify-center rounded-r-md text-muted-foreground transition-colors outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
                >
                  <Plus className="size-4" />
                </button>
              </div>
              <span className="text-sm text-muted-foreground">
                In stock, ready to ship
              </span>
            </div>

            <div className="flex items-center gap-3">
              <a
                href="#add-to-cart"
                className={cn(
                  buttonVariants({ size: "lg" }),
                  "flex-1 gap-2"
                )}
              >
                <ShoppingBag className="size-4" />
                {addToCartLabel}
              </a>
              <button
                type="button"
                aria-label="Add to wishlist"
                className={cn(
                  buttonVariants({ variant: "outline", size: "icon-lg" })
                )}
              >
                <Heart className="size-4" />
              </button>
            </div>

            <a
              href="#buy-now"
              className={cn(
                buttonVariants({ variant: "outline", size: "lg" }),
                "w-full"
              )}
            >
              {buyNowLabel}
            </a>
          </div>

          {/* Trust row */}
          <ul className="mt-8 flex flex-wrap items-center gap-x-5 gap-y-2 text-xs text-muted-foreground">
            <li className="flex items-center gap-1.5">
              <Truck className="size-4 text-primary" />
              Free shipping
            </li>
            <li aria-hidden className="h-3 w-px bg-border" />
            <li className="flex items-center gap-1.5">
              <RotateCcw className="size-4 text-primary" />
              30-day returns
            </li>
            <li aria-hidden className="h-3 w-px bg-border" />
            <li className="flex items-center gap-1.5">
              <ShieldCheck className="size-4 text-primary" />
              Secure checkout
            </li>
          </ul>
        </motion.div>
      </motion.div>
    </section>
  )
}

const mockShapes = [
  "rounded-[2rem]",
  "rounded-full",
  "rounded-[2rem] rotate-45",
  "rounded-tl-[3rem] rounded-br-[3rem]",
]

function ProductMock({
  index = 0,
  thumb = false,
}: {
  index?: number
  thumb?: boolean
}) {
  const shape = mockShapes[index % mockShapes.length]
  return (
    <div
      aria-hidden
      className="relative size-full bg-gradient-to-br from-primary/20 via-muted to-background"
    >
      <div className="absolute inset-0 flex items-center justify-center">
        <div
          className={cn(
            "bg-gradient-to-br from-primary/50 via-primary/20 to-transparent",
            thumb ? "size-1/2" : "size-1/2 shadow-xl shadow-primary/10",
            shape
          )}
        />
      </div>
      {!thumb && (
        <div
          className="absolute inset-0 opacity-[0.4] [mask-image:radial-gradient(circle_at_center,transparent_55%,black)]"
          style={{
            backgroundImage:
              "radial-gradient(circle at 30% 30%, var(--color-primary, currentColor) 1px, transparent 0)",
            backgroundSize: "20px 20px",
          }}
        />
      )}
    </div>
  )
}