# Gallery 1: filterable gallery with lightbox

> A photo gallery: filter chips with counts, a gap-free masonry of tiles that fade in, and a lightbox with caption, counter, thumbnail strip, arrow-key and swipe navigation, and a link to the original.

A shadcn/ui block from the [@ballmac](https://ui.ballmac.com/?utm_source=shadcn.exchange) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/ballmac/gallery-1).

- Page, with a live preview: https://shadcn.exchange/r/ballmac/gallery-1
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/ballmac/gallery-1-light-ae4498a41ba47384.png
- Registry item: https://ui.ballmac.com/r/gallery-1.json
- Categories: blocks, gallery
- Listed: 2026-10-18, source last changed 2026-10-18

## Install

```bash
npx shadcn@latest add https://shadcn.exchange/r/ballmac/gallery-1.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `lucide-react`
- Registry items: `utils`, `https://ui.ballmac.com/r/button.json`, `https://ui.ballmac.com/r/cover-art.json`, `https://ui.ballmac.com/r/dialog.json`, `https://ui.ballmac.com/r/direction.json`, `https://ui.ballmac.com/r/masonry-grid.json`, `https://ui.ballmac.com/r/media.json`, `https://ui.ballmac.com/r/skeleton.json`, `https://ui.ballmac.com/r/state-view.json`, `https://ui.ballmac.com/r/return-focus.json`

## Files

### `registry/ballmac/components/blocks/gallery-1/gallery-1.tsx`

```tsx
// Ballmac UI: Gallery 1. https://ui.ballmac.com/blocks/gallery-1
"use client"

import * as React from "react"
import { ArrowUpRight, ChevronLeft, ChevronRight, Expand, ImageOff, X } from "lucide-react"

import { Button } from "@/components/ballmac/button"
import { CoverArt, type CoverArtVariant } from "@/components/ballmac/cover-art"
import { Dialog, DialogClose, DialogContent, DialogDescription, DialogTitle } from "@/components/ballmac/dialog"
import { MasonryGrid, MasonryItem } from "@/components/ballmac/masonry-grid"
import { Media, type MediaSource } from "@/components/ballmac/media"
import { Skeleton } from "@/components/ballmac/skeleton"
import { useReturnFocus } from "@/lib/ballmac/return-focus"
import { frameProps, StateView, type StateProps } from "@/components/ballmac/state-view"
import { useDirection } from "@/lib/ballmac/direction"
import { cn } from "@/lib/utils"

type Gallery1Ratio = "square" | "landscape" | "portrait" | "tall"

type Gallery1Item = {
  id: string
  title: string
  /** A sentence under the picture in the lightbox. */
  caption?: string
  /** A short line next to the title ("Lisbon, 2026"). */
  meta?: string
  category: string
  /** The picture. Leave out and art is drawn from the id, so the gallery looks finished before you have photos. */
  src?: MediaSource
  /** Describes the picture. Required when `src` is set; use "" for decoration. */
  alt?: string
  /** The shape of the tile. */
  ratio?: Gallery1Ratio
  /** Where the full-size original lives. Adds a link in the lightbox. */
  href?: string
}

type Gallery1Props = Omit<React.ComponentProps<"section">, "title"> &
  StateProps & {
    items?: Gallery1Item[]
    title?: string
    description?: string
    /** The category shown at first, or "all". */
    defaultCategory?: string
    /** Called when a picture is opened in the lightbox. */
    onOpen?: (item: Gallery1Item) => void
    /** The id of a picture that is open in the lightbox at first. */
    defaultOpenId?: string
  }

const ratios: Record<Gallery1Ratio, string> = {
  square: "aspect-square",
  landscape: "aspect-[3/2]",
  portrait: "aspect-[4/5]",
  tall: "aspect-[3/4]",
}
const variants: CoverArtVariant[] = ["mesh", "rings", "bars", "dots", "waves"]

const sample: Gallery1Item[] = [
  { id: "g1", title: "The Lisbon studio", caption: "Our first office, above a bakery. The morning light on the long table is why we stayed.", meta: "Lisbon, 2024", category: "Offices", ratio: "landscape" },
  { id: "g2", title: "Launch day", caption: "Everyone in one room when version 2 went live, with pizza that arrived late.", meta: "March 2026", category: "Events", ratio: "portrait" },
  { id: "g3", title: "The first prototype", caption: "A paper model of the sidebar, taped to a window and argued over for a week.", meta: "2023", category: "Product", ratio: "square" },
  { id: "g4", title: "Design offsite", caption: "Two days in the mountains, no laptops until the second afternoon.", meta: "Serra da Estrela", category: "Team", ratio: "tall" },
  { id: "g5", title: "Standing desks", caption: "The new floor, with room for the support team to sit next to engineering.", meta: "Porto, 2026", category: "Offices", ratio: "square" },
  { id: "g6", title: "Conference talk", caption: "Mara on stage explaining why empty states deserve a designer.", meta: "Berlin, 2025", category: "Events", ratio: "landscape" },
  { id: "g7", title: "Sketching the dashboard", caption: "Four weeks of whiteboards before a single pixel was drawn.", meta: "2025", category: "Product", ratio: "portrait" },
  { id: "g8", title: "Friday lunch", caption: "The one rule: nobody talks about work for the first twenty minutes.", meta: "Every week", category: "Team", ratio: "landscape" },
  { id: "g9", title: "The wall of notes", caption: "Every customer letter we have kept, pinned where we pass it daily.", meta: "Lisbon", category: "Offices", ratio: "tall" },
  { id: "g10", title: "Community meetup", caption: "Sixty people, a projector and a very patient host.", meta: "Lisbon, 2025", category: "Events", ratio: "square" },
  { id: "g11", title: "Color study", caption: "Forty swatches narrowed to five over one long lunch.", meta: "2024", category: "Product", ratio: "landscape" },
  { id: "g12", title: "The whole team", caption: "Twenty-three people on one staircase, and one blurry photographer.", meta: "Autumn 2026", category: "Team", ratio: "portrait" },
]

function GallerySkeleton() {
  const heights = ["h-56", "h-72", "h-48", "h-64", "h-80", "h-52"]
  return (
    <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3" aria-hidden="true">
      {heights.map((h, i) => (
        <div key={i} className="grid gap-2">
          <Skeleton className={cn("rounded-2xl", h)} />
          <Skeleton className="h-3.5 w-1/2" />
        </div>
      ))}
    </div>
  )
}

function Picture({ item, index, className, fit = "cover" }: { item: Gallery1Item; index: number; className?: string; fit?: "cover" | "contain" }) {
  return (
    <Media
      media={item.src}
      alt={item.alt ?? ""}
      fit={fit}
      fill
      className={cn("absolute inset-0", className)}
      fallback={<CoverArt seed={item.id} variant={variants[index % variants.length]} grid className="absolute inset-0" />}
    />
  )
}

/**
 * A photo gallery: filter chips with counts, a gap-free masonry of tiles that fade in, and a lightbox with the caption, a counter, a
 * thumbnail strip, arrow-key and swipe navigation (mirrored in right-to-left), and a link to the original.
 */
function Gallery1({
  items = sample,
  title = "Life at Acme",
  description = "Offices, launches and the people behind the work.",
  defaultCategory = "all",
  onOpen,
  defaultOpenId,
  state = "ready",
  onRetry,
  emptyState,
  errorState,
  className,
  ...props
}: Gallery1Props) {
  const id = React.useId()
  const dir = useDirection()
  const [category, setCategory] = React.useState(defaultCategory)
  const [openIndex, setOpenIndex] = React.useState<number | null>(() => {
    const at = defaultOpenId ? items.filter((i) => defaultCategory === "all" || i.category === defaultCategory).findIndex((i) => i.id === defaultOpenId) : -1
    return at >= 0 ? at : null
  })
  const swipe = React.useRef<number | null>(null)
  const ready = state === "ready" || state === "empty"

  const categories = [...new Set(items.map((i) => i.category))]
  const list = state === "empty" ? [] : items
  const filtered = list.filter((i) => category === "all" || i.category === category)
  const current = openIndex !== null ? filtered[openIndex] : undefined
  const returnFocus = useReturnFocus(current !== undefined)
  const indexInAll = (item: Gallery1Item) => items.findIndex((x) => x.id === item.id)

  const go = (delta: number) => setOpenIndex((i) => (i === null || !filtered.length ? i : (i + delta + filtered.length) % filtered.length))
  const open = (index: number) => {
    setOpenIndex(index)
    const item = filtered[index]
    if (item) onOpen?.(item)
  }

  function onKeyDown(e: React.KeyboardEvent) {
    const forward = dir === "rtl" ? "ArrowLeft" : "ArrowRight"
    const back = dir === "rtl" ? "ArrowRight" : "ArrowLeft"
    if (e.key === forward) {
      e.preventDefault()
      go(1)
    } else if (e.key === back) {
      e.preventDefault()
      go(-1)
    } else if (e.key === "Home") {
      e.preventDefault()
      setOpenIndex(0)
    } else if (e.key === "End") {
      e.preventDefault()
      setOpenIndex(filtered.length - 1)
    }
  }

  const content = (
    <>
      {categories.length > 1 ? (
        <fieldset className="mb-8" disabled={!ready}>
          <legend className="sr-only">Category</legend>
          <div className="flex flex-wrap gap-2">
            {[{ key: "all", label: "All", n: list.length }, ...categories.map((c) => ({ key: c, label: c, n: list.filter((i) => i.category === c).length }))].map((c) => (
              <label
                key={c.key}
                className="has-[:focus-visible]:ring-ring/50 hover:bg-accent/60 has-[:checked]:bg-foreground has-[:checked]:text-background has-[:checked]:border-foreground flex cursor-pointer items-center gap-2 rounded-full border px-4 py-1.5 text-sm transition-colors duration-150 has-[:focus-visible]:ring-[3px]"
              >
                <input type="radio" name={`${id}-cat`} value={c.key} checked={category === c.key} onChange={() => setCategory(c.key)} className="sr-only" />
                {c.label}{" "}
                <span className="text-xs tabular-nums opacity-70">{c.n}</span>
              </label>
            ))}
          </div>
        </fieldset>
      ) : null}

      {filtered.length ? (
        <MasonryGrid key={category} columns={{ base: 1, sm: 2, lg: 3 }} gap="lg" reveal>
          {filtered.map((item, i) => (
            <MasonryItem key={item.id}>
              <button
                type="button"
                aria-haspopup="dialog"
                onClick={() => open(i)}
                className="group/tile focus-visible:ring-ring/50 block w-full rounded-2xl text-start outline-none focus-visible:ring-[3px]"
              >
                <span className={cn("bg-muted relative block w-full overflow-hidden rounded-2xl border shadow-xs", ratios[item.ratio ?? "landscape"])}>
                  <Picture item={item} index={indexInAll(item)} className="transition-transform duration-700 ease-out group-hover/tile:scale-[1.05] motion-reduce:transition-none motion-reduce:group-hover/tile:scale-100" />
                  <span aria-hidden="true" className="absolute inset-0 bg-gradient-to-t from-black/30 to-transparent opacity-0 transition-opacity duration-300 group-hover/tile:opacity-100 group-focus-visible/tile:opacity-100 motion-reduce:transition-none" />
                  <span aria-hidden="true" className="bg-background/90 text-foreground absolute end-3 top-3 flex size-8 translate-y-1 items-center justify-center rounded-full opacity-0 shadow-sm backdrop-blur transition-all duration-300 group-hover/tile:translate-y-0 group-hover/tile:opacity-100 group-focus-visible/tile:translate-y-0 group-focus-visible/tile:opacity-100 motion-reduce:transition-none">
                    <Expand className="size-4" />
                  </span>
                </span>
                <span className="mt-3 flex items-baseline justify-between gap-3 px-1">
                  <span className="min-w-0 text-sm font-medium tracking-tight text-pretty">{item.title}</span>
                  <span className="text-muted-foreground shrink-0 text-xs">{item.category}</span>
                </span>
                {item.meta ? <span className="text-muted-foreground mt-0.5 block px-1 text-xs">{item.meta}</span> : null}
              </button>
            </MasonryItem>
          ))}
        </MasonryGrid>
      ) : (
        <div className="bg-card rounded-2xl border px-6 py-16 text-center">
          <span aria-hidden="true" className="bg-muted mx-auto flex size-12 items-center justify-center rounded-full">
            <ImageOff className="size-6" />
          </span>
          <h2 className="mt-5 text-lg font-semibold tracking-tight">{emptyState?.title ?? (list.length ? "Nothing in this category yet" : "No pictures yet")}</h2>
          <p className="text-muted-foreground mx-auto mt-1.5 max-w-sm text-sm text-pretty">{emptyState?.description ?? (list.length ? "Try another category to see more." : "Pictures will appear here as they are added.")}</p>
          {list.length ? (
            <Button variant="outline" size="sm" className="mt-5" onClick={() => setCategory("all")}>
              Show everything
            </Button>
          ) : (
            emptyState?.action
          )}
        </div>
      )}
    </>
  )

  return (
    <section data-slot="gallery-1" data-state={state} aria-labelledby={`${id}-title`} className={cn("mx-auto w-full max-w-6xl px-4 py-12 sm:px-6", className)} {...frameProps(props)}>
      <header className="mb-10 max-w-2xl">
        <h1 id={`${id}-title`} className="text-4xl font-semibold tracking-[-0.04em] text-balance sm:text-5xl">
          {title}
        </h1>
        <p className="text-muted-foreground mt-3 text-base text-pretty">{description}</p>
      </header>
      <StateView state={state === "loading" || state === "error" ? state : "ready"} onRetry={onRetry} label="Loading pictures" loading={<GallerySkeleton />} error={{ title: errorState?.title ?? "The pictures could not be loaded", description: errorState?.description ?? "Check your connection and try again.", ...errorState }}>
        {content}
      </StateView>

      <Dialog open={current !== undefined} onOpenChange={(o) => !o && setOpenIndex(null)}>
        {current ? (
          <DialogContent showCloseButton={false} onKeyDown={onKeyDown} {...returnFocus} className="max-w-5xl gap-0 overflow-hidden p-0 sm:p-0">
            <DialogTitle className="sr-only">{current.title}</DialogTitle>
            <DialogDescription className="sr-only">
              Picture {(openIndex ?? 0) + 1} of {filtered.length}. Use the left and right arrow keys to move between pictures.
            </DialogDescription>
            <div
              className="bg-muted relative h-[min(60dvh,34rem)] touch-pan-y select-none"
              onPointerDown={(e) => (swipe.current = e.clientX)}
              onPointerUp={(e) => {
                if (swipe.current === null) return
                const dx = e.clientX - swipe.current
                swipe.current = null
                if (Math.abs(dx) > 60) go((dx < 0 ? 1 : -1) * (dir === "rtl" ? -1 : 1))
              }}
            >
              <Picture item={current} index={indexInAll(current)} fit="contain" />
              <DialogClose aria-label="Close" className="bg-background/85 hover:bg-background focus-visible:ring-ring/50 absolute end-3 top-3 z-10 flex size-9 items-center justify-center rounded-full border shadow-sm backdrop-blur outline-none focus-visible:ring-[3px]">
                <X aria-hidden="true" className="size-4" />
              </DialogClose>
              {filtered.length > 1 ? (
                <>
                  <button type="button" aria-label="Previous picture" onClick={() => go(-1)} className="bg-background/85 hover:bg-background focus-visible:ring-ring/50 absolute start-3 top-1/2 flex size-10 -translate-y-1/2 items-center justify-center rounded-full border shadow-sm backdrop-blur outline-none focus-visible:ring-[3px]">
                    <ChevronLeft aria-hidden="true" className="size-5 rtl:rotate-180" />
                  </button>
                  <button type="button" aria-label="Next picture" onClick={() => go(1)} className="bg-background/85 hover:bg-background focus-visible:ring-ring/50 absolute end-3 top-1/2 flex size-10 -translate-y-1/2 items-center justify-center rounded-full border shadow-sm backdrop-blur outline-none focus-visible:ring-[3px]">
                    <ChevronRight aria-hidden="true" className="size-5 rtl:rotate-180" />
                  </button>
                </>
              ) : null}
            </div>
            <div className="flex flex-wrap items-start gap-x-6 gap-y-3 border-t p-5">
              <div className="min-w-0 flex-1 basis-72">
                <h2 className="text-lg font-semibold tracking-tight text-pretty">{current.title}</h2>
                {current.caption ? <p className="text-muted-foreground mt-1 text-sm leading-relaxed text-pretty">{current.caption}</p> : null}
                <p className="text-muted-foreground mt-2 text-xs">
                  {current.category}
                  {current.meta ? ` · ${current.meta}` : ""}
                </p>
              </div>
              <div className="flex items-center gap-3">
                <p className="text-muted-foreground text-sm tabular-nums" aria-hidden="true">
                  {(openIndex ?? 0) + 1} / {filtered.length}
                </p>
                {current.href ? (
                  <a href={current.href} className="focus-visible:ring-ring/50 hover:bg-accent inline-flex h-8 items-center gap-1.5 rounded-md border px-3 text-[13px] font-medium outline-none focus-visible:ring-[3px]">
                    Open original
                    <ArrowUpRight aria-hidden="true" className="size-3.5 rtl:-scale-x-100" />
                  </a>
                ) : null}
              </div>
            </div>
            {filtered.length > 1 ? (
              <div className="flex gap-2 overflow-x-auto border-t p-3" role="group" aria-label="All pictures">
                {filtered.map((item, i) => (
                  <button
                    key={item.id}
                    type="button"
                    aria-label={`Picture ${i + 1}: ${item.title}`}
                    aria-current={i === openIndex ? "true" : undefined}
                    onClick={() => setOpenIndex(i)}
                    className={cn("bg-muted focus-visible:ring-ring/50 relative size-14 shrink-0 overflow-hidden rounded-lg border outline-none transition-[opacity,box-shadow] duration-150 focus-visible:ring-[3px]", i === openIndex ? "ring-foreground ring-2 ring-offset-2 ring-offset-card" : "opacity-60 hover:opacity-100")}
                  >
                    <Picture item={item} index={indexInAll(item)} />
                  </button>
                ))}
              </div>
            ) : null}
          </DialogContent>
        ) : null}
      </Dialog>
    </section>
  )
}

export { Gallery1, type Gallery1Props, type Gallery1Item, type Gallery1Ratio }
```
