← GalleryOpen in v0
Gallery 02 — Filmstrip
Horizontal scroll-snap filmstrip of tall tiles with interactive mesh shader visuals or images, edge-fade mask, and a minimal drag/scroll affordance — the strip is the interaction.
$ npx shadcn@latest add @ludusvibe/gallery-02Code
Installed to components/sections/gallery-02.tsx — it's yours to edit.
"use client"
import * as React from "react"
import { MeshBackdrop } from "@/components/ludus/mesh-backdrop"
import { Reveal } from "@/components/ludus/reveal"
import { cn } from "@/lib/utils"
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 Gallery02Props = {
index?: string
label?: string
note?: string
title?: React.ReactNode
/** Scroll affordance shown right of the heading. */
hint?: string
items?: GalleryItem[]
/** Heading element — use "h1" when this is the page's main heading. */
as?: "h1" | "h2"
className?: string
}
const DEFAULT_ITEMS: GalleryItem[] = [
{ title: "Arclight — checkout" },
{ title: "Mira — pricing" },
{ title: "Northbeam — onboarding" },
{ title: "Fathom — lifecycle" },
{ title: "Kelp — activation" },
]
/** Per-tile shader variation — kept inside the 0.4–0.7 backdrop band. */
const MESH_PARAMS = [
{ intensity: 0.5, speed: 0.32 },
{ intensity: 0.65, speed: 0.4 },
{ intensity: 0.4, speed: 0.26 },
{ intensity: 0.7, speed: 0.44 },
{ intensity: 0.55, speed: 0.35 },
]
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 =
"group relative aspect-[3/4] w-[70vw] shrink-0 snap-start overflow-hidden rounded-none border bg-background sm:w-[340px]"
if (item.href) {
return (
<a
href={item.href}
aria-label={item.title}
className={cn(
tileClass,
"outline-none focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:ring-inset"
)}
>
{content}
</a>
)
}
return <div className={tileClass}>{content}</div>
}
export function Gallery02({
index = "07",
label = "Gallery",
note = "Field notes",
title = "Recent work, in motion.",
hint = "Drag / scroll →",
items = DEFAULT_ITEMS,
as: Heading = "h2",
className,
}: Gallery02Props) {
return (
<section className={cn("bg-background", className)}>
<div className="mx-auto max-w-6xl px-6 py-20 md:py-28">
<Reveal>
{/* Indexed caption bar */}
<div 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>
</div>
{/* Heading row */}
<div className="mt-10 flex items-baseline justify-between gap-8">
<Heading className="text-3xl font-medium tracking-tight text-balance md:text-4xl">
{title}
</Heading>
<span
aria-hidden
className="hidden shrink-0 text-xs tracking-[0.2em] text-muted-foreground uppercase sm:block"
>
{hint}
</span>
</div>
</Reveal>
{/* Filmstrip */}
<Reveal delay={0.1}>
<div
className="mt-10 flex snap-x snap-mandatory gap-4 overflow-x-auto pb-2 [mask-image:linear-gradient(to_right,transparent,black_4%,black_96%,transparent)] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
{items.map((item, i) => (
<Tile key={item.title} item={item} i={i} />
))}
</div>
</Reveal>
</div>
</section>
)
}