Collection

Collection 01 — Product Grid

Shop listing with an eyebrow header, client-side category filter chips and a responsive grid of product cards with sale badges, hover quick-add, wishlist, price with compare-at and star ratings.

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

Code

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

"use client"

import * as React from "react"
import { motion } from "motion/react"
import { Heart, Star } 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"

export type CollectionProduct = {
  name: string
  category: string
  price: number
  /** Original price for a strike-through comparison. */
  compareAt?: number
  /** 0–5, drives the star rating. */
  rating: number
  /** Optional corner flag, e.g. "Sale" | "New" | "Bestseller". */
  badge?: string
  /** Real product image; falls back to a CSS mock when omitted. */
  image?: string
}

export type Collection01Props = {
  eyebrow?: string
  title?: React.ReactNode
  description?: string
  /** Category filters. "All" is prepended automatically if absent. */
  categories?: string[]
  products?: CollectionProduct[]
  /** Currency symbol prefixed to every price. */
  currency?: string
  /** Heading element — use "h1" when this is the page's main heading. */
  as?: "h1" | "h2"
  className?: string
}

const defaultProducts: CollectionProduct[] = [
  {
    name: "Meridian Wool Overshirt",
    category: "Outerwear",
    price: 148,
    compareAt: 185,
    rating: 4.6,
    badge: "Sale",
  },
  {
    name: "Field Canvas Weekender",
    category: "Bags",
    price: 210,
    rating: 5,
    badge: "Bestseller",
  },
  {
    name: "Everyday Merino Crew",
    category: "Knitwear",
    price: 92,
    rating: 4.4,
  },
  {
    name: "Horizon Selvedge Denim",
    category: "Trousers",
    price: 128,
    rating: 4.8,
    badge: "New",
  },
  {
    name: "Atlas Leather Cardholder",
    category: "Bags",
    price: 58,
    rating: 4.2,
  },
  {
    name: "Coastline Linen Shirt",
    category: "Knitwear",
    price: 76,
    compareAt: 95,
    rating: 4.5,
    badge: "Sale",
  },
  {
    name: "Ridgeline Quilted Vest",
    category: "Outerwear",
    price: 164,
    rating: 4.7,
  },
  {
    name: "Traveler Wide-Leg Trouser",
    category: "Trousers",
    price: 118,
    rating: 4.3,
    badge: "New",
  },
]

/** Rotating token-only gradient mocks, varied subtly by index. */
const mockGradients = [
  "bg-gradient-to-br from-primary/25 via-muted to-background",
  "bg-gradient-to-tr from-secondary/40 via-muted to-background",
  "bg-gradient-to-b from-muted via-primary/10 to-background",
  "bg-gradient-to-bl from-primary/15 via-secondary/30 to-background",
]

function StarRating({ rating }: { rating: number }) {
  const filled = Math.round(rating)
  return (
    <div
      className="flex items-center gap-0.5"
      role="img"
      aria-label={`Rated ${rating} out of 5`}
    >
      {Array.from({ length: 5 }).map((_, i) => (
        <Star
          key={i}
          aria-hidden
          className={cn(
            "size-3.5",
            i < filled
              ? "fill-primary text-primary"
              : "fill-transparent text-muted-foreground/40"
          )}
        />
      ))}
    </div>
  )
}

function ProductCard({
  product,
  index,
  currency,
}: {
  product: CollectionProduct
  index: number
  currency: string
}) {
  const gradient = mockGradients[index % mockGradients.length]

  return (
    <motion.article variants={fadeUp} className="group flex flex-col">
      <div className="relative aspect-[4/5] overflow-hidden rounded-xl border bg-card">
        {product.image ? (
          <img
            src={product.image}
            alt={product.name}
            className="size-full object-cover transition-transform duration-500 group-hover:scale-[1.03]"
          />
        ) : (
          <div
            aria-hidden
            className={cn(
              "size-full transition-transform duration-500 group-hover:scale-[1.03]",
              gradient
            )}
          />
        )}

        {product.badge && (
          <Badge
            variant="secondary"
            className="absolute top-3 left-3 shadow-sm"
          >
            {product.badge}
          </Badge>
        )}

        <button
          type="button"
          aria-label={`Add ${product.name} to wishlist`}
          className="absolute top-3 right-3 flex size-8 items-center justify-center rounded-full border bg-background/70 text-muted-foreground backdrop-blur transition-colors hover:text-primary focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
        >
          <Heart className="size-4" />
        </button>

        <div className="absolute inset-x-2 bottom-2 translate-y-2 opacity-0 transition-all duration-300 group-hover:translate-y-0 group-hover:opacity-100 focus-within:translate-y-0 focus-within:opacity-100">
          <button
            type="button"
            className={cn(
              buttonVariants({ size: "sm" }),
              "w-full shadow-sm"
            )}
          >
            Add to cart
          </button>
        </div>
      </div>

      <div className="mt-4 flex flex-col gap-1">
        <div className="flex items-start justify-between gap-3">
          <h3 className="line-clamp-1 text-sm font-medium">{product.name}</h3>
        </div>
        <p className="text-xs text-muted-foreground">{product.category}</p>
        <div className="mt-1 flex items-center justify-between gap-3">
          <div className="flex items-baseline gap-2">
            <span className="text-sm font-medium tabular-nums">
              {currency}
              {product.price}
            </span>
            {product.compareAt != null && (
              <span className="text-sm text-muted-foreground line-through tabular-nums">
                {currency}
                {product.compareAt}
              </span>
            )}
          </div>
          <StarRating rating={product.rating} />
        </div>
      </div>
    </motion.article>
  )
}

export function Collection01({
  eyebrow = "Shop",
  title = "The autumn collection",
  description = "Considered essentials built to outlast the season — natural fabrics, honest construction, and a palette that goes with everything already in your closet.",
  categories,
  products = defaultProducts,
  currency = "$",
  as: Heading = "h2",
  className,
}: Collection01Props) {
  const derivedCategories = React.useMemo(() => {
    const base =
      categories ?? Array.from(new Set(products.map((p) => p.category)))
    return ["All", ...base.filter((c) => c !== "All")]
  }, [categories, products])

  const [activeCategory, setActiveCategory] = React.useState("All")

  const filtered = React.useMemo(
    () =>
      activeCategory === "All"
        ? products
        : products.filter((p) => p.category === activeCategory),
    [activeCategory, products]
  )

  return (
    <section className={cn("bg-background px-6 py-20 md:py-28", className)}>
      <div className="mx-auto max-w-6xl">
        <motion.div
          initial="hidden"
          whileInView="visible"
          viewport={viewport}
          variants={stagger()}
          className="max-w-2xl"
        >
          <motion.p
            variants={fadeUp}
            className="text-sm font-medium tracking-widest text-primary uppercase"
          >
            {eyebrow}
          </motion.p>
          <motion.div variants={fadeUp}>
            <Heading className="mt-4 font-heading text-4xl font-semibold tracking-tight text-balance md:text-5xl">
              {title}
            </Heading>
          </motion.div>
          <motion.p
            variants={fadeUp}
            className="mt-5 text-lg text-pretty text-muted-foreground"
          >
            {description}
          </motion.p>
        </motion.div>

        <div className="mt-12 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
          <div
            role="group"
            aria-label="Filter by category"
            className="-mx-1 flex flex-wrap gap-2 px-1"
          >
            {derivedCategories.map((category) => {
              const active = category === activeCategory
              return (
                <button
                  key={category}
                  type="button"
                  aria-pressed={active}
                  onClick={() => setActiveCategory(category)}
                  className={cn(
                    buttonVariants({
                      variant: active ? "default" : "ghost",
                      size: "sm",
                    }),
                    !active && "text-muted-foreground"
                  )}
                >
                  {category}
                </button>
              )
            })}
          </div>
          <p
            aria-live="polite"
            className="shrink-0 text-sm text-muted-foreground tabular-nums"
          >
            {filtered.length} {filtered.length === 1 ? "product" : "products"}
          </p>
        </div>

        <motion.div
          key={activeCategory}
          initial="hidden"
          whileInView="visible"
          viewport={viewport}
          variants={stagger(0.06)}
          className="mt-10 grid gap-x-6 gap-y-10 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
        >
          {filtered.map((product, index) => (
            <ProductCard
              key={product.name}
              product={product}
              index={index}
              currency={currency}
            />
          ))}
        </motion.div>
      </div>
    </section>
  )
}