Gallery

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

Code

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