Gallery
A masonry / justified / uniform thumbnail grid — a real layout engine plus a zero-CLS lazy-media pipeline, a view-agnostic multi-select model (click · shift-range · marquee · keyboard), a built-in lightbox, FLIP re-layout motion, drag-reorder, and windowed virtualization for large asset libraries.
@garn/ui/galleryDefault
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`.
2 selected · click, ⇧-click for a range, ⌘/Ctrl-click to toggle, or drag a box across the tiles. Arrows + Space navigate.
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.
Click any image (or focus + Enter) to open the lightbox — ←/→ to page, Esc to close.
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.
Hover a tile for its grip, then drag to reorder (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- Source
- gallery.tsx
- Deps
lucide-react- Registry deps
utilsuse-controllable-stateuse-resize-observeruse-intersection-observeruse-isomorphic-layout-effectuse-merged-refuse-prefers-reduced-motionsortabledialogcarousel
Import
import { Gallery } from "@garn/ui/gallery";Props
Generated from the component's TypeScript types.
layout- Description
- The layout engine — the structural axis (reflected `data-layout`).
- Type
"justified" | "masonry" | "uniform"- Default
uniform
selectionMode- Description
- Enable selection. `none` (default) 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. Default true; always disabled under `prefers-reduced-motion`.
- Type
boolean
aspectRatio- Description
- Crop ratio for `uniform` tiles (w ÷ h). Default 1 (square).
- Type
number
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"
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
minTileWidth- Description
- Auto-fill floor when `columns` is unset (px). Default: token `min-tile` (240).
- Type
number
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`. Default 400.
- Type
number
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. Default: 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
selectedIds- Description
- Controlled selection — the ids of selected records (view-agnostic).
- Type
readonly GalleryItemId[]
targetRowHeight- Description
- Target row height for `justified` (px). Default: token `row-height` (224).
- Type
number
virtualHeight- Description
- The scroll-viewport height (px) for `virtualized`. Default 640.
- Type
number
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
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` (default) crops to fill; `contain` letterboxes.
- Type
"contain" | "cover"
loading- Description
- `lazy` (default) defers load until near the viewport; `eager` loads now.
- Type
"eager" | "lazy"
placeholder- Description
- Placeholder while loading: blur-up LQIP, a shimmer skeleton, or none.
- Type
"blur" | "none" | "skeleton"
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"
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
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 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"]| 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 |
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.
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.
Enforceable contracts
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`.
- Status
- Beta
- Version
v0.3.0- Introduced
v0.2.0
garn doesn't yet record a per-release version history. This reflects the component's current lifecycle state; full changelog entries will appear once the metadata pipeline emits a lifecycle.history field.