Product

Product 02 — Product Ledger

Minimal Swiss product page on a golden-ratio split: indexed caption bar, a 3D-tilt product frame beside a buy panel framed by a pulsing glow border with an animated price, hairline size cells and a risk-reversal CTA over grain.

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

Code

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

"use client"

import * as React from "react"
import { motion } from "motion/react"
import { ArrowRight, Minus, Plus, Star } from "lucide-react"

import { buttonVariants } from "@/components/ui/button"
import { AnimatedNumber } from "@/components/ludus/animated-number"
import { GlowBorder } from "@/components/ludus/glow-border"
import { Grain } from "@/components/ludus/grain"
import { TiltCard } from "@/components/ludus/tilt-card"
import { fadeUp, stagger, viewport } from "@/lib/motion"
import { cn } from "@/lib/utils"

export type Product02Props = {
  index?: string
  label?: string
  note?: string
  /** Product name — the primary heading. */
  title?: string
  price?: number
  /** Optional strike-through original price. */
  compareAt?: number
  currency?: string
  rating?: number
  reviewCount?: number
  /** Up to three size options. */
  sizes?: string[]
  /** Real product image; swaps in for the CSS mock. */
  image?: { src: string; alt: string }
  sku?: string
  ctaLabel?: string
  guarantee?: string
  /** Heading element — use "h1" when this is the page's main heading. */
  as?: "h1" | "h2"
  className?: string
}

export function Product02({
  index = "01",
  label = "Product",
  note = "In stock · Ships in 24h",
  title = "Meridian Field Jacket",
  price = 248,
  compareAt = 320,
  currency = "$",
  rating = 4.8,
  reviewCount = 412,
  sizes = ["S", "M", "L"],
  image,
  sku = "MRD-0248",
  ctaLabel = "Add to cart",
  guarantee = "Free returns within 30 days",
  as: Heading = "h2",
  className,
}: Product02Props) {
  const [size, setSize] = React.useState(() => sizes[Math.min(1, sizes.length - 1)] ?? "")
  const [qty, setQty] = React.useState(1)

  const roundedRating = Math.round(rating)

  return (
    <section className={cn("relative overflow-hidden bg-background py-20 md:py-28", className)}>
      <Grain />

      <motion.div
        initial="hidden"
        whileInView="visible"
        viewport={viewport}
        variants={stagger(0.09)}
        className="relative mx-auto max-w-6xl px-6"
      >
        {/* Indexed caption bar */}
        <motion.div
          variants={fadeUp}
          className="flex items-baseline justify-between border-b pb-3 text-xs tracking-[0.2em] text-muted-foreground uppercase"
        >
          <span>
            <span className="text-primary">{index}</span> — {label}
          </span>
          <span className="hidden sm:block">{note}</span>
        </motion.div>

        {/* Golden split: visual / buy panel */}
        <div className="mt-10 grid gap-px border bg-border lg:grid-cols-[1.618fr_1fr]">
          {/* LEFT — product visual */}
          <motion.div variants={fadeUp} className="bg-background p-4 md:p-6">
            <TiltCard maxTilt={4} className="h-full">
              <div className="relative aspect-[4/5] overflow-hidden bg-gradient-to-br from-primary/20 via-muted to-background">
                {image ? (
                  <img
                    src={image.src}
                    alt={image.alt}
                    className="absolute inset-0 h-full w-full object-cover"
                  />
                ) : (
                  <div aria-hidden className="absolute inset-0">
                    <div className="absolute left-1/2 top-1/2 h-2/3 w-2/3 -translate-x-1/2 -translate-y-1/2 rounded-full bg-primary/15 blur-2xl" />
                    <div className="absolute inset-0 bg-[linear-gradient(to_right,transparent,transparent_calc(50%-0.5px),var(--border)_50%,transparent_calc(50%+0.5px)),linear-gradient(to_bottom,transparent,transparent_calc(50%-0.5px),var(--border)_50%,transparent_calc(50%+0.5px))] opacity-40" />
                    <div className="absolute left-1/2 top-1/2 size-40 -translate-x-1/2 -translate-y-1/2 rotate-45 border border-primary/30" />
                    <div className="absolute left-1/2 top-1/2 size-40 -translate-x-1/2 -translate-y-1/2 border border-foreground/10" />
                  </div>
                )}
                <span className="absolute left-3 top-3 text-xs tracking-[0.2em] uppercase text-muted-foreground tabular-nums">
                  {sku} / {index}
                </span>
              </div>
            </TiltCard>
          </motion.div>

          {/* RIGHT — buy panel */}
          <motion.div variants={fadeUp} className="bg-background">
            <div className="relative h-full">
              <GlowBorder intensity={0.45} />
              <div className="relative flex h-full flex-col gap-8 p-6 md:p-8">
                {/* Name + rating */}
                <div>
                  <Heading className="font-heading text-2xl font-medium tracking-tight text-balance text-foreground md:text-3xl">
                    {title}
                  </Heading>
                  <div className="mt-3 flex items-center gap-2 text-sm">
                    <span className="flex items-center gap-0.5" aria-hidden>
                      {Array.from({ length: 5 }).map((_, i) => (
                        <Star
                          key={i}
                          className={cn(
                            "size-3.5",
                            i < roundedRating ? "fill-current text-primary" : "text-muted-foreground/40"
                          )}
                        />
                      ))}
                    </span>
                    <span className="text-muted-foreground tabular-nums">
                      {rating.toFixed(1)} · {reviewCount} reviews
                    </span>
                  </div>
                </div>

                {/* Price */}
                <div className="flex items-baseline gap-3">
                  <span className="font-heading text-4xl font-medium tracking-tight text-foreground tabular-nums md:text-5xl">
                    <AnimatedNumber value={price} prefix={currency} />
                  </span>
                  {compareAt != null && compareAt > price && (
                    <span className="text-lg text-muted-foreground/70 line-through tabular-nums">
                      {currency}
                      {compareAt}
                    </span>
                  )}
                </div>

                {/* Size selector */}
                {sizes.length > 0 && (
                  <div>
                    <div className="flex items-baseline justify-between text-xs tracking-[0.2em] uppercase text-muted-foreground">
                      <span>Size</span>
                      <span className="tabular-nums text-muted-foreground/70">{size}</span>
                    </div>
                    <div className="mt-3 grid gap-px border bg-border" style={{ gridTemplateColumns: `repeat(${sizes.length}, minmax(0, 1fr))` }}>
                      {sizes.map((s) => (
                        <button
                          key={s}
                          type="button"
                          aria-pressed={size === s}
                          onClick={() => setSize(s)}
                          className={cn(
                            "bg-background py-3 text-sm tabular-nums transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring",
                            size === s
                              ? "bg-primary text-primary-foreground"
                              : "text-muted-foreground hover:text-foreground"
                          )}
                        >
                          {s}
                        </button>
                      ))}
                    </div>
                  </div>
                )}

                {/* Quantity stepper */}
                <div className="flex items-center justify-between">
                  <span className="text-xs tracking-[0.2em] uppercase text-muted-foreground">Quantity</span>
                  <div className="flex items-center border">
                    <button
                      type="button"
                      aria-label="Decrease quantity"
                      onClick={() => setQty((q) => Math.max(1, q - 1))}
                      className="flex size-9 items-center justify-center text-muted-foreground transition-colors hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
                    >
                      <Minus className="size-4" />
                    </button>
                    <span className="w-10 text-center text-sm tabular-nums" aria-live="polite">
                      {qty}
                    </span>
                    <button
                      type="button"
                      aria-label="Increase quantity"
                      onClick={() => setQty((q) => q + 1)}
                      className="flex size-9 items-center justify-center text-muted-foreground transition-colors hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
                    >
                      <Plus className="size-4" />
                    </button>
                  </div>
                </div>

                {/* CTA + risk reversal */}
                <div className="mt-auto">
                  <button
                    type="button"
                    className={cn(buttonVariants({ size: "lg" }), "group w-full justify-between")}
                  >
                    {ctaLabel}
                    <ArrowRight className="transition-transform group-hover:translate-x-0.5" />
                  </button>
                  <p className="mt-3 border-t pt-3 text-xs tracking-wide uppercase text-muted-foreground/70">
                    {guarantee}
                  </p>
                </div>
              </div>
            </div>
          </motion.div>
        </div>
      </motion.div>
    </section>
  )
}