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.
The normal view. Pick a state to see what the block shows while it loads, is empty or fails.
// 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 }Installation
$ pnpm dlx shadcn@latest add @ballmac/gallery-1Install the dependencies.
$ pnpm add lucide-reactAdd the Ballmac items it builds on.
$ pnpm dlx shadcn@latest add @ballmac/button @ballmac/cover-art @ballmac/dialog @ballmac/direction @ballmac/masonry-grid @ballmac/media @ballmac/skeleton @ballmac/state-view @ballmac/return-focusCopy the source into your project.
components/ballmac/blocks/gallery-1/gallery-1.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 }Update the import paths to match your project setup.
Usage
"use client"
import { Gallery1 } from "@/components/ballmac/blocks/gallery-1/gallery-1"
export default function Gallery1Demo() {
return <Gallery1 />
}States
Pass state and the block does the rest: skeletons that match its layout, a clear empty message, an error you can retry, and announcements for screen readers.
Loading
Skeleton tiles of different heights under the real heading, announced as loading.
state="loading"No pictures yet
Says pictures will appear; a category with nothing in it offers Show everything. emptyState sets the copy.
state="empty" (or items={[]})Could not load
An alert with Try again (onRetry).
state="error"
Quality scorecard
Every applicable check passes
Measured in Chromium 153.0.8010.12 on October 19, 2026, on version 1.0.0, across 6 examples.
Passed: Accessibility scan
No serious or critical issues in light and dark, on a phone and a desktop (axe-core 4.13.0, WCAG 2.1 AA and best-practice rules).
Passed: Small screens
No sideways scrolling at 360, 390 or 1280 px wide.
Passed: Reduced motion
Every endless animation stops when the visitor asks for less motion.
Passed: Right to left
Mirrors correctly in Arabic, Hebrew, Persian and Urdu layouts (0% of elements off the mirror, limit 15%).
Passed: Clean console
No console errors or React hydration errors while loading.
Passed: Keyboard
34 tab stops, each showing a visible change when it takes focus. 3 documented keys.
Passed: Loading, empty and error
Ships loading, empty, error states, each with an example.
Passed: Automated tests
Covered by gallery-1.test.tsx.
For information: Footprint
5.4 KB of source (gzip) in 1 file. Installs lucide-react.
Automated checks find a subset of problems and do not replace testing with a screen reader and a keyboard in your own app. What they miss.
Dependencies
- npm
- lucide-react
Use with AI
A gallery page. <Gallery1 items={[{ id, title, caption, meta, category, src, alt, ratio: 'square' | 'landscape' | 'portrait' | 'tall', href }]} title description defaultCategory onOpen />. src takes a URL, an image with alt text or any element (a next/image); without it the tile shows art drawn from the id. Categories come from the items. With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI Gallery 1: filterable gallery with lightbox (@ballmac/gallery-1) to this project with the shadcn MCP, then use it where it fits.
Use it for
- A photo, event, studio or portfolio gallery
- Showing work or places with captions and the option to see each larger
Not for
- A product image carousel on a store page (use carousel)
- Logos (use logo-cloud-1)
- One hero picture (use media with a frame)
Registry JSON: https://ui.ballmac.com/r/gallery-1.json
Free to use in personal and commercial projects.
Ballmac UI Pro
Need the whole screen, not one piece?
150 premium blocks (dashboards, settings, calendars, mail, shops, onboarding), a private shadcn registry and Figma tokens. Blocks that show data come with loading, empty and error states built in.
Not ready? Try three Pro blocks free
Leave your email and the blocks appear right here, ready to copy. Yours to use in any project.