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.
@garn/ui/galleryOn 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.
Lightbox
Lightbox
The built-in lightbox — click (or focus + Enter) to open a full-screen viewer; ←/→ page, Esc closes.
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.
npx garn-ui add gallery- 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
| Selector | State | Description |
|---|---|---|
[data-selected=true] | selected | A selected tile — draws the selection ring + fills the check circle. |
[data-active] | active | The roving-tabindex active tile (the single tab stop); tracks focus + the last click. |
[data-status=loaded] | loaded | A media slot whose image has decoded — the placeholder fades out under it. |
[data-status=error] | error | A media slot whose source failed — shows the broken-image fallback. |
[data-reorderable] | reorderable | The container when drag-reorder is active (a grip appears on each tile). |
[data-dragging] | dragging | A tile being dragged, or the container while a reorder drag is in flight. |
| Property | Token | Tier |
|---|---|---|
gap | --garn-gallery-gap-md | component · air |
tileRadius | --garn-gallery-radius | component |
minTileWidth | --garn-gallery-min-tile | component · identity |
rowHeight | --garn-gallery-row-height | component · identity |
rowUnit | --garn-gallery-row-unit | component |
placeholder | --garn-gallery-placeholder | component |
selectionRing | --garn-brand-solidbrandable | semantic |
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
- Give each item
width/height(oraspectRatio) 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 picksinglevsmultiple. - Compose
Gallery.Media+Gallery.OverlayinsiderenderItem; keep Root's tile cell. - Use
lightboxfor the built-in viewer, or driveGallery.Lightboxfrom your ownonActivate. - Choose
reorderableORvirtualized— not both; virtualize the big libraries, reorder the small moodboards.
- Don't replace or restyle the tile cell's
role/data-slot— it carries selection, keyboard, drag, and FLIP; put custom content insiderenderIteminstead. - 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
reorderableandvirtualized— 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
Provide an accessible name via `aria-label` or `aria-labelledby` — required for the selectable listbox, recommended always.
when the gallery has items
Keyboard
| ArrowLeftArrowRight | Move the active tile one step in DOM (reading) order. |
| ArrowUpArrowDown | Move to the nearest tile above / below by center geometry (layout-aware). |
| HomeEnd | Move to the first / last tile. |
| Space | Toggle selection of the active tile (multiple) / select it (single); activate it when the gallery is activatable-only. |
| ShiftArrowUpArrowDownArrowLeftArrowRight | Extend the selection from the range anchor to the moved tile (multiple). |
| MetaControla | Select all tiles (⌘/Ctrl+A, multiple). |
| Enter | Activate the active tile (open the lightbox / fire onActivate); otherwise select it. |
| Escape | Clear the selection (or close the lightbox when it's open). |
- 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`.