Collection

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-02
Open in v0

Code

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>
  )
}