Skip to content

Gallery

A masonry / justified / uniform thumbnail grid — a real layout engine with a zero-CLS lazy-media pipeline, multi-select, and a built-in lightbox.

Betav0.4.2added in v0.2.0@garn/ui/gallery
On this page

Default

Column-packed variable heights (Pinterest) — native `grid-template-rows: masonry` on Safari 26+, a DOM-order computed-span fallback elsewhere.

Layout

Masonry

Column-packed variable heights (Pinterest) — native `grid-template-rows: masonry` on Safari 26+, a DOM-order computed-span fallback elsewhere.

Justified

Flickr / Google-Photos row-fill — each row scales to exactly fill the container width; the last row is left-aligned.

Uniform

An aspect-cropped auto-fill grid (DAM / Instagram) — the cheapest, most common shape.

Columns

A fixed column count (`columns={3}`) with a non-square crop ratio, instead of auto-fill.

Media

Placeholder

Blur-up LQIP placeholders — a tiny data-URI blurs up as the full image decodes (zero CLS).

Broken

A broken source falls back to a muted broken-image glyph without collapsing the tile.

Selection

Selectable

Multi-select — click, ⇧-range, ⌘/Ctrl-toggle, marquee drag, and arrows + Space; controlled via `selectedIds`.

Overlay

Tile metadata in a `Gallery.Overlay` (title + dimensions) that appears on hover / focus / selected.

Motion

Motion

FLIP re-layout — tiles glide between positions when the order changes (shuffle to see it); off under reduced motion.

Reorder

Reorder

Drag-to-reorder — hover a tile for its grip and drag, or focus the grip and use Space + arrows. Powered by the sortable primitive.

Virtualization

Virtualized

240 assets, windowed — only the tiles near the scroll viewport are mounted.

240 assets, windowed — only the tiles near the viewport are mounted. Scroll the panel below.

States

Empty

The empty state rendered via the `empty` prop when there are no items.

No assets yet.

Installation

garn is copy-in — the garn CLI writes this component's source into your project, so you own and can edit it. It resolves any base files and installs the npm dependencies below for you.

terminal
npx garn-ui add gallery
First time? Set up garn in your project
Registry
gallery
Deps
lucide-react
Registry deps
utilsuse-controllable-stateuse-resize-observeruse-intersection-observeruse-isomorphic-layout-effectuse-merged-refuse-prefers-reduced-motionsortabledialogcarousel

Import

import { Gallery, Gallery.Media, Gallery.Overlay, Gallery.Lightbox } from "@garn/ui/gallery";

Anatomy

The parts this component is built from — compose them to assemble it.

GalleryRequired[data-slot="gallery"]

Root + engine (generic over the item type — extend GalleryItemData with your own fields). Owns the layout math, the view-agnostic selection model, the roving-tabindex keyboard, FLIP motion, the drag machine (via useSortable), the virtualizer, and the built-in lightbox. Data-driven: pass items + optional renderItem.

Gallery.Media[data-slot="gallery-media"]

The lazy image part used by the default tile and available inside a custom renderItem. Zero-CLS: reserves the aspect box, defers load until near the viewport (IntersectionObserver + native loading=lazy), shows a blur-up LQIP or skeleton, and falls back to a broken-image glyph on error.

Gallery.Overlay[data-slot="gallery-overlay"]

A hover/selected scrim slot for tile metadata — place it inside a renderItem. Pointer-transparent (never blocks selection); position puts the gradient on the top or bottom edge.

Gallery.Lightbox[data-slot="gallery-lightbox"]

The controlled full-screen viewer (Dialog + Carousel) — driven by index (null = closed). Gallery renders it internally for the lightbox prop, but it's exported so you can open it from your own onActivate.

Props

The component's public props and their types.

layout
Description
The layout engine — the structural axis (reflected `data-layout`).
Type
"justified" | "masonry" | "uniform"
Default
"uniform"
selectionMode
Description
Enable selection. `none` keeps the read-only `role="list"`; `single`/`multiple` fork it to a selectable `role="listbox"`.
Type
"multiple" | "none" | "single"
Default
"none"
size
Description
Tile density (reserved for per-size tuning; currently sets `data-size`).
Type
"lg" | "md" | "sm"
Default
"md"
animate
Description
Animate tiles between positions (FLIP) when the set reorders/filters. Always disabled under `prefers-reduced-motion`.
Type
boolean
Default
true
aspectRatio
Description
Crop ratio for `uniform` tiles (w ÷ h).
Type
number
Default
1
columns
Description
Fixed column count (uniform · masonry). Omit to auto-fill by `minTileWidth`.
Type
number
defaultSelectedIds
Description
Uncontrolled initial selection.
Type
readonly GalleryItemId[]
empty
Description
Rendered when `items` is empty.
Type
React.ReactNode
gap
Description
Gutter between tiles — maps to the `--garn-gallery-gap-*` tokens.
Type
"lg" | "md" | "sm"
Default
"md"
itemsRequired
Description
The records to render. Their `aspectRatio` (or width/height) drives packing.
Type
readonly T[]
lightbox
Description
Open a built-in lightbox (composed Dialog + Carousel) on activation.
Type
boolean
Default
false
minTileWidth
Description
Auto-fill floor when `columns` is unset (px) — mirrors the `min-tile` token.
Type
number
Default
240
onActivate
Description
Activate a tile (Enter / double-click, or single click when not selectable).
Type
(item: T, ctx: { index: number; }) => void
onReorder
Description
Fires with the next item order after a reorder.
Type
(items: T[]) => void
onSelectionChange
Description
Fires with the full selected-id list on every selection change.
Type
(ids: GalleryItemId[]) => void
overscan
Description
Extra px rendered above/below the viewport in `virtualized`.
Type
number
Default
400
renderItem
Description
Render a tile's content. Defaults to a lazy `Gallery.Media` from the item.
Type
(item: T, ctx: { index: number; layout: GalleryLayout; }) => React.ReactNode
renderSlide
Description
Render a lightbox slide. Defaults to the item's full image, letterboxed.
Type
(item: T, ctx: { index: number; }) => React.ReactNode
reorderable
Description
Enable drag-to-reorder (composes the `sortable` primitive: pointer + keyboard + touch long-press + announcements). Each tile grows a grip.
Type
boolean
Default
false
selectedIds
Description
Controlled selection — the ids of selected records (view-agnostic).
Type
readonly GalleryItemId[]
targetRowHeight
Description
Target row height for `justified` (px) — mirrors the `row-height` token.
Type
number
Default
224
virtualHeight
Description
The scroll-viewport height (px) for `virtualized`.
Type
number
Default
640
virtualized
Description
Window the render: compute an absolute layout and mount only the tiles near the viewport. Requires a scrollable height (`virtualHeight` or a CSS height). Trades native CSS masonry for a JS packing model. Wins over `reorderable` (windowing unmounts off-screen tiles, which drag can't reach).
Type
boolean
Default
false

Plus 279 inherited native <div> attributes.

Gallery.Media

alt
Description
No description.
Type
string
aspectRatio
Description
Own aspect box (w ÷ h) when used standalone, outside a Gallery.Item.
Type
number
blurDataURL
Description
Tiny blurred data-URI for `placeholder="blur"`.
Type
string
fallback
Description
Rendered in place of a broken image. Default: a muted broken-image glyph.
Type
React.ReactNode
fit
Description
`cover` crops to fill; `contain` letterboxes.
Type
"contain" | "cover"
Default
"cover"
loading
Description
`lazy` defers load until near the viewport; `eager` loads now.
Type
"eager" | "lazy"
Default
"lazy"
placeholder
Description
Placeholder while loading: blur-up LQIP, a shimmer skeleton, or none.
Type
"blur" | "none" | "skeleton"
Default
"blur"
src
Description
No description.
Type
string

Plus 287 inherited native HTML attributes.

Gallery.Overlay

position
Description
Which edge the scrim sits on.
Type
"bottom" | "top"
Default
"bottom"

Plus 280 inherited native HTML attributes.

Gallery.Lightbox

indexRequired
Description
The open slide index; `null` closes the lightbox (controlled).
Type
number
itemsRequired
Description
No description.
Type
readonly T[]
label
Description
Accessible name for the viewer.
Type
string
Default
"Image viewer"
onIndexChange
Description
No description.
Type
(index: number | null) => void
renderSlide
Description
Render a slide. Default: the item's full image, letterboxed.
Type
(item: T, ctx: { index: number; }) => React.ReactNode

Styling

Target these data-slots and states, and remap these tokens, to restyle without forking the component.

[data-slot="gallery"][data-slot="gallery-item"][data-slot="gallery-media"][data-slot="gallery-media-fallback"][data-slot="gallery-overlay"][data-slot="gallery-check"][data-slot="gallery-marquee"][data-slot="gallery-viewport"][data-slot="gallery-empty"][data-slot="gallery-lightbox"][data-slot="gallery-lightbox-counter"]

States

SelectorState
[data-selected=true]selected
[data-active]active
[data-status=loaded]loaded
[data-status=error]error
[data-reorderable]reorderable
[data-dragging]dragging
PropertyTokenTier
gap--garn-gallery-gap-mdcomponent · air
tileRadius--garn-gallery-radiuscomponent
minTileWidth--garn-gallery-min-tilecomponent · identity
rowHeight--garn-gallery-row-heightcomponent · identity
rowUnit--garn-gallery-row-unitcomponent
placeholder--garn-gallery-placeholdercomponent
selectionRing--garn-brand-solidbrandablesemantic

When to use

Displaying a large set of images / thumbnails where the visuals ARE the content: a DAM / asset library, a photo stream, an e-commerce product grid, a Pinterest-style moodboard, a portfolio. Use masonry for variable-height pins, justified for a photo stream that fills each row, uniform for a cropped contact sheet. Add selection for batch ops, a lightbox to inspect, reorder to curate, virtualization to scale.

Reach for something else when

  • A single-row horizontal scroller (use Carousel). Records better scanned as sortable rows of fields than as tiles (use Table / List / Data-list). One image with a fixed ratio (use Aspect-ratio + a plain <img>). A calendar heatmap of activity (use Activity-grid). Grouping/faceting or a bulk-action bar above the grid — those belong in the CollectionView layer, not the Gallery.

Overview

Gallery renders a large, heterogeneous set of visual records — DAM assets, product shots, moodboard pins — as a real layout engine, not a <div> of cards. One layout axis wears three looks: uniform (an aspect-cropped auto-fill grid), masonry (column-packed variable heights, reaching for the native grid-template-rows: masonry on Safari 26+ and a DOM-order-preserving computed-span fallback everywhere else), and justified (Flickr / Google-Photos row-fill). It is data-driven — pass items (+ an optional renderItem) rather than composing tiles by hand — because the packing math needs each tile's aspect ratio up front, which is what lets it justify and pack without a measure-render-remeasure thrash. Every tile reserves its aspect box (zero CLS), lazy-loads with a blur-up placeholder, and skeletons while pending.

Guidelines

Give items their aspect ratios. Provide width/height (or aspectRatio) on each record so masonry and justified pack correctly on the first paint with no reflow when images load. uniform crops to a single aspectRatio and doesn't need per-item ratios.

Selection is a view-agnostic Set<id>. Turn it on with selectionMode and control it with selectedIds/onSelectionChange. Because it's ids (not indices), the selection survives a filter, a reorder, or a CollectionView view switch. multiple gives you click · ⇧-range · ⌘/Ctrl-toggle · marquee drag · ⌘/Ctrl+A · a full roving-tabindex keyboard.

Compose inside renderItem, don't replace the cell. Root wraps your content in the tile cell that carries selection, keyboard, drag, and FLIP. Put Gallery.Media + Gallery.Overlay inside renderItem; never restyle the cell's role/data-slot.

Reach for the built-ins. lightbox for a batteries-included viewer (or drive the exported Gallery.Lightbox from your own onActivate); reorderable to curate a moodboard; virtualized for a huge DAM. reorderable and virtualized are mutually exclusive (windowing unmounts the tiles a drag needs) — pick one. Always give the gallery an aria-label.

Best practices

Do
  • Give each item width/height (or aspectRatio) so masonry/justified pack with zero layout shift.
  • Name the gallery with aria-label / aria-labelledby (required for a selectable listbox).
  • Control selection with selectedIds/onSelectionChange (view-agnostic ids), and pick single vs multiple.
  • Compose Gallery.Media + Gallery.Overlay inside renderItem; keep Root's tile cell.
  • Use lightbox for the built-in viewer, or drive Gallery.Lightbox from your own onActivate.
  • Choose reorderable OR virtualized — not both; virtualize the big libraries, reorder the small moodboards.
Don't
  • Don't replace or restyle the tile cell's role/data-slot — it carries selection, keyboard, drag, and FLIP; put custom content inside renderItem instead.
  • Don't skip item dimensions for masonry/justified — without an aspect the layout can't reserve the box and tiles repack when images load.
  • Don't set both reorderable and virtualized — windowing unmounts off-screen tiles that a drag can't reach (virtualized wins, reorder is dropped).
  • Don't use Gallery for a single-row scroller (Carousel) or a field table (Table) — it's for a packed 2D grid of visuals.
  • Don't strip the focus ring or silence the reduced-motion gating.

Troubleshooting

Masonry / justified tiles jump or repack after the images finish loading.

Cause. The layout math needs each item's aspect ratio up front; without width/height (or aspectRatio) it can't reserve the box, so it repacks once images report their natural size.

Fix. Give every record width + height (or an explicit aspectRatio). uniform is exempt — it crops to the single aspectRatio prop.

Setting both reorderable and virtualized doesn't let tiles drag (and a console warning fires).

Cause. Windowing unmounts off-screen tiles, which a drag can't reach — the two modes are mutually exclusive and virtualized wins.

Fix. Pick one: reorderable for a full-DOM moodboard, virtualized for a huge library. Don't set both.

Selection, keyboard, drag, or FLIP breaks when I customize a tile.

Cause. Root wraps each renderItem in the tile cell (the option/listitem role, selection check, drag handle, data-gallery-id). Replacing or restyling that cell's role/data-slot strips the machinery.

Fix. Put custom content INSIDE renderItem — compose Gallery.Media + Gallery.Overlay. Don't render your own Gallery.Item wrapper or override its role/data-slot.

A console warning asks for an accessible name.

Cause. A gallery with items has no aria-label / aria-labelledby; a selectable listbox requires a name.

Fix. Pass aria-label (or aria-labelledby) to the Gallery.

Masonry looks different across browsers.

Cause. grid-template-rows: masonry is native only on Safari 26+; elsewhere Gallery falls back to a computed grid-row-end: span (still DOM-order preserving).

Fix. This is expected progressive enhancement — both paths pack correctly, no action needed. It gets better as browsers ship the native primitive.

onActivate and the built-in lightbox — which opens the viewer?

Cause. lightbox opens the built-in Dialog + Carousel viewer on activation; onActivate is your own hook. They compose — with both set, onActivate fires AND the lightbox opens.

Fix. Set lightbox for the batteries-included viewer, or leave it off and drive the exported Gallery.Lightbox (controlled by index) from your onActivate for full control.

Accessibility

Role
list (read-only) · listbox + option (selectable) · group + button (activatable-only)
ARIA APG
Listbox (rowless multi-select) / Grid
Focus
Roving tabindex — one tile carries `tabindex=0`, the rest `-1`; arrows move focus (and it) between tiles. Focus resolves by stable `data-gallery-id`. In `virtualized` the active tile is kept mounted even when scrolled out so the tab stop never vanishes. Don't strip the focus ring.

Accessibility requirements

warn

Provide an accessible name via `aria-label` or `aria-labelledby` — required for the selectable listbox, recommended always.

when the gallery has items

Keyboard

ArrowLeftArrowRightMove the active tile one step in DOM (reading) order.
ArrowUpArrowDownMove to the nearest tile above / below by center geometry (layout-aware).
HomeEndMove to the first / last tile.
SpaceToggle selection of the active tile (multiple) / select it (single); activate it when the gallery is activatable-only.
ShiftArrowUpArrowDownArrowLeftArrowRightExtend the selection from the range anchor to the moved tile (multiple).
MetaControlaSelect all tiles (⌘/Ctrl+A, multiple).
EnterActivate the active tile (open the lightbox / fire onActivate); otherwise select it.
EscapeClear the selection (or close the lightbox when it's open).
A1.3.1Info and RelationshipsA2.1.1KeyboardA2.4.3Focus OrderAA2.4.7Focus VisibleAA2.5.7Dragging MovementsA4.1.2Name, Role, Value
  • The container's role tracks the interaction model: read-only it is a `role=list` of `role=listitem` tiles; `selectionMode` forks it to a `role=listbox` of `role=option` tiles with `aria-selected` (and `aria-multiselectable` in `multiple`); an activatable-only gallery (lightbox / onActivate without selection) is a `role=group` of focusable `role=button` tiles. Masonry / justified have no honest rows, so a rowless listbox is used rather than a dishonest `role=grid` with fake `role=row` wrappers.
  • Roving tabindex: exactly one tile is the tab stop (`tabindex=0`, `data-active`); the arrows move focus. Left/right walk DOM (reading) order; up/down pick the nearest tile by center geometry (from the absolute model when virtualized, so navigation works over unmounted tiles). Home/End jump to the first/last. Focus is resolved by stable `data-gallery-id`, not array position.
  • The auto selection check is decorative (`aria-hidden`) — the option's `aria-selected` is the single source of truth, so no nested control is introduced inside the listbox option. The marquee rectangle is `aria-hidden`.
  • A gallery with items must be named (`aria-label` / `aria-labelledby`) — required for the selectable listbox, good practice always; a dev warning fires when it's missing.
  • The lightbox is a Dialog (scrim, focus trap, Esc-to-close, an sr-only title '{label} — n of N') wrapping a Carousel; prev/next controls and ←/→ page the slides, and a counter announces position.
  • Media is zero-CLS: every tile reserves its aspect box before load, so nothing shifts. Motion (FLIP glide, hover zoom, placeholder fades) is disabled under `prefers-reduced-motion`.