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