← CollectionOpen in v0
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-01Code
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>
)
}