← CollectionOpen in v0
Collection 02 — Shop by Collection
Editorial shop-by-collection tiles on a hairline Swiss grid: indexed caption bar and an asymmetric bento of interactive token-tinted mesh-shader tiles with collection names and item counts — or your own images via props.
$ npx shadcn@latest add @ludusvibe/collection-02Code
Installed to components/sections/collection-02.tsx — it's yours to edit.
"use client"
import * as React from "react"
import { motion } from "motion/react"
import { ArrowUpRight } from "lucide-react"
import { Grain } from "@/components/ludus/grain"
import { MeshBackdrop } from "@/components/ludus/mesh-backdrop"
import { fadeUp, stagger, viewport } from "@/lib/motion"
import { cn } from "@/lib/utils"
export type Collection = {
name: string
count: number
/** Real image — rendered instead of the default shader tile. */
image?: string
href?: string
}
export type Collection02Props = {
index?: string
label?: string
note?: string
title?: React.ReactNode
description?: string
collections?: Collection[]
/** Heading element — use "h1" when this is the page's main heading. */
as?: "h1" | "h2"
className?: string
}
const DEFAULT_COLLECTIONS: Collection[] = [
{ name: "The Overcoat Edit", count: 24, href: "#" },
{ name: "Merino Knits", count: 18, href: "#" },
{ name: "Raw Denim", count: 12, href: "#" },
{ name: "Cold-Weather Boots", count: 9, href: "#" },
{ name: "Accessories", count: 31, href: "#" },
]
/** Per-tile shader variation — kept inside the 0.4–0.7 backdrop band. */
const MESH_PARAMS = [
{ intensity: 0.7, speed: 0.32 },
{ intensity: 0.45, speed: 0.28 },
{ intensity: 0.55, speed: 0.4 },
{ intensity: 0.4, speed: 0.3 },
{ intensity: 0.6, speed: 0.36 },
]
/**
* Asymmetric bento: the first tile is the featured hero (2×2 on lg),
* the rest fill a hairline Swiss grid around it.
*/
const SPANS = [
"col-span-2 row-span-2 min-h-80 md:min-h-[34rem]",
"min-h-56 md:min-h-64",
"min-h-56 md:min-h-64",
"min-h-56 md:min-h-64",
"min-h-56 md:min-h-64",
]
function Tile({ collection, i }: { collection: Collection; i: number }) {
const mesh = MESH_PARAMS[i % MESH_PARAMS.length]
const featured = i === 0
const content = (
<>
{collection.image ? (
<img
src={collection.image}
alt={collection.name}
className="absolute inset-0 size-full object-cover transition-transform duration-500 group-hover:scale-[1.03]"
/>
) : (
<MeshBackdrop
interactive
intensity={mesh.intensity}
speed={mesh.speed}
className="transition-transform duration-500 group-hover:scale-[1.03]"
/>
)}
{/* Readability scrim — works in both themes. */}
<div
aria-hidden
className="absolute inset-0 bg-gradient-to-t from-background/90 via-background/30 to-transparent"
/>
{/* Overlaid content, pinned bottom-left. */}
<div className="relative flex h-full items-end p-5 md:p-6">
<div className="flex w-full items-end justify-between gap-4">
<div>
<h3
className={cn(
"font-heading font-medium tracking-tight text-foreground text-balance",
featured ? "text-2xl md:text-3xl" : "text-lg md:text-xl"
)}
>
{collection.name}
</h3>
<p className="mt-1 text-xs tabular-nums tracking-[0.2em] text-muted-foreground uppercase">
{collection.count} items
</p>
</div>
<ArrowUpRight
aria-hidden
className="size-5 shrink-0 text-muted-foreground transition-transform duration-300 group-hover:-translate-y-1 group-hover:translate-x-1 group-hover:text-primary"
/>
</div>
</div>
</>
)
return (
<motion.div
variants={fadeUp}
className={cn("group relative overflow-hidden bg-background", SPANS[i % SPANS.length])}
>
{collection.href ? (
<a
href={collection.href}
aria-label={`${collection.name} — ${collection.count} items`}
className="relative block size-full outline-none focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:ring-inset"
>
{content}
</a>
) : (
content
)}
</motion.div>
)
}
export function Collection02({
index = "02",
label = "Collections",
note = "Autumn / Winter 25",
title = "Shop by collection.",
description = "Five capsule edits, curated for the season — each one built to be worn together.",
collections = DEFAULT_COLLECTIONS,
as: Heading = "h2",
className,
}: Collection02Props) {
return (
<section className={cn("relative overflow-hidden bg-background", className)}>
<Grain />
<motion.div
initial="hidden"
whileInView="visible"
viewport={viewport}
variants={stagger(0.08)}
className="relative mx-auto max-w-6xl px-6 py-20 md:py-28"
>
{/* 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>
{/* Heading zone */}
<motion.div variants={fadeUp} className="mt-10">
<Heading className="text-3xl font-medium tracking-tight text-balance md:text-4xl">
{title}
</Heading>
{description ? (
<p className="mt-2 max-w-[60ch] text-pretty text-muted-foreground">{description}</p>
) : null}
</motion.div>
{/* Editorial bento — hairline frame */}
<div className="mt-10 grid grid-cols-2 gap-px border bg-border lg:grid-cols-4">
{collections.map((collection, i) => (
<Tile key={collection.name} collection={collection} i={i} />
))}
</div>
</motion.div>
</section>
)
}