Gallery

Gallery 01 — Shader Gallery Grid

Editorial gallery on a hairline Swiss grid: indexed caption bar, short heading, and four asymmetric tiles of interactive token-tinted mesh shaders — or your own images via props.

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

Code

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

"use client"

import * as React from "react"
import { motion } from "motion/react"

import { buttonVariants } from "@/components/ui/button"
import { MeshBackdrop } from "@/components/ludus/mesh-backdrop"
import { fadeUp, stagger, viewport } from "@/lib/motion"
import { cn } from "@/lib/utils"

type Cta = { label: string; href: string }

export type GalleryItem = {
  title: string
  /** Real image — rendered instead of the default shader tile. */
  src?: string
  /** Required alongside `src`. Falls back to `title`. */
  alt?: string
  href?: string
}

export type Gallery01Props = {
  index?: string
  label?: string
  note?: string
  title?: React.ReactNode
  description?: string
  items?: GalleryItem[]
  /** Single ghost CTA under the grid — pass `null` to remove. */
  cta?: Cta | null
  /** Heading element — use "h1" when this is the page's main heading. */
  as?: "h1" | "h2"
  className?: string
}

const DEFAULT_ITEMS: GalleryItem[] = [
  { title: "Checkout flows" },
  { title: "Pricing pages" },
  { title: "Onboarding" },
  { title: "Lifecycle email" },
]

/** Per-tile shader variation — kept inside the 0.4–0.7 backdrop band. */
const MESH_PARAMS = [
  { intensity: 0.55, speed: 0.35 },
  { intensity: 0.4, speed: 0.28 },
  { intensity: 0.7, speed: 0.42 },
  { intensity: 0.5, speed: 0.32 },
]

/** Asymmetric editorial spans: 7/5 over 5/7. */
const SPANS = [
  "sm:col-span-7",
  "sm:col-span-5",
  "sm:col-span-5",
  "sm:col-span-7",
]

function Tile({ item, i }: { item: GalleryItem; i: number }) {
  const mesh = MESH_PARAMS[i % MESH_PARAMS.length]

  const content = (
    <>
      {item.src ? (
        <img
          src={item.src}
          alt={item.alt ?? item.title}
          className="absolute inset-0 size-full object-cover transition-transform group-hover:scale-[1.02]"
        />
      ) : (
        <MeshBackdrop
          intensity={mesh.intensity}
          speed={mesh.speed}
          className="transition-transform group-hover:scale-[1.02]"
        />
      )}
      <span className="absolute bottom-2 left-2 flex items-baseline gap-2 bg-background/70 px-2 py-1 text-xs tracking-[0.2em] text-foreground uppercase backdrop-blur">
        <span aria-hidden className="text-primary">
          {String(i + 1).padStart(2, "0")}
        </span>
        {item.title}
      </span>
    </>
  )

  const tileClass = cn(
    "group relative col-span-1 min-h-60 overflow-hidden bg-background md:min-h-80",
    SPANS[i % SPANS.length]
  )

  return (
    <motion.div variants={fadeUp} className={cn("grid", tileClass)}>
      {item.href ? (
        <a
          href={item.href}
          aria-label={item.title}
          className="relative block outline-none focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:ring-inset"
        >
          {content}
        </a>
      ) : (
        content
      )}
    </motion.div>
  )
}

export function Gallery01({
  index = "07",
  label = "Gallery",
  note = "Selected work",
  title = "Proof you can look at.",
  description = "Four surfaces we redesigned last quarter — each one shipped, measured, and still converting.",
  items = DEFAULT_ITEMS,
  cta = { label: "View all work", href: "#" },
  as: Heading = "h2",
  className,
}: Gallery01Props) {
  return (
    <section className={cn("bg-background", className)}>
      <motion.div
        initial="hidden"
        whileInView="visible"
        viewport={viewport}
        variants={stagger(0.08)}
        className="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>
          <p className="mt-2 max-w-[60ch] text-pretty text-muted-foreground">{description}</p>
        </motion.div>

        {/* Editorial grid — hairline frame */}
        <div className="mt-10 grid grid-cols-1 gap-px border bg-border sm:grid-cols-12">
          {items.map((item, i) => (
            <Tile key={item.title} item={item} i={i} />
          ))}
        </div>

        {cta ? (
          <motion.div variants={fadeUp} className="mt-8">
            <a
              href={cta.href}
              className={cn(
                buttonVariants({ variant: "ghost" }),
                "px-0 hover:bg-transparent hover:underline"
              )}
            >
              {cta.label} →
            </a>
          </motion.div>
        ) : null}
      </motion.div>
    </section>
  )
}