A responsive masonry image grid that opens a swipeable, keyboard-navigable lightbox. Tiles keep each image's ratio, captions show in the lightbox only, and the lightbox is a fullscreen `Modal`.

pnpm add @behivetech/organisms.gallery

This is the snapshot of v0.1.7, the current version, as recorded on 2026-11-10. The live page runs its examples →

A responsive masonry image grid that opens a swipeable, keyboard-navigable lightbox. Tiles keep each image's ratio, captions show in the lightbox only, and the lightbox is a fullscreen Modal.

Usage

1import { Gallery } from "@behivetech/organisms.gallery";
2
3<Gallery
4 images={[
5 {
6 src: "data:image/svg+xml;utf8,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20width='1200'%20height='800'%3E%3Crect%20width='100%25'%20height='100%25'%20fill='hsl(200%2080%25%2055%25)'/%3E%3C/svg%3E",
7 alt: "Living room",
8 width: 1200,
9 height: 800,
10 caption: "The mural",
11 },
12 {
13 src: "data:image/svg+xml;utf8,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20width='800'%20height='1200'%3E%3Crect%20width='100%25'%20height='100%25'%20fill='hsl(40%2080%25%2055%25)'/%3E%3C/svg%3E",
14 alt: "Kitchen",
15 width: 800,
16 height: 1200,
17 },
18 {
19 src: "data:image/svg+xml;utf8,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20width='1000'%20height='1000'%3E%3Crect%20width='100%25'%20height='100%25'%20fill='hsl(120%2080%25%2055%25)'/%3E%3C/svg%3E",
20 alt: "Garden",
21 width: 1000,
22 height: 1000,
23 caption: "The garden at dusk",
24 },
25 ]}
26/>;

The jsx block above runs live on the docs site. Imports are stripped and every registry export is in scope, so you can compose with other components too.

Behaviour

  • Columns. columns is a number or { xs, md, lg } (below 48rem, from 48rem, from 64rem). The default is { xs: 2, md: 3, lg: 4 }. It is one list in images[] order, so Tab, reading and lightbox order agree; the grid drops each tile into the column that frees up first. All of it is CSS, so there is no reflow after hydration, and up to 8 columns are supported.
  • Tall images. A tile keeps its image's ratio up to about 3:1 (height over width). The row span is capped at 150 rows, so a taller image is cropped to that height in its thumbnail (object-fit: cover); the lightbox still shows the whole image. Crop very tall images before you pass them in if the thumbnail should show a particular part.
  • Lightbox. Previous and Next are disabled on the first and last image; it does not wrap. ArrowLeft and ArrowRight step, Home and End jump, Escape closes. On close, focus returns to the thumbnail of the image showing at that moment.
  • Swipe. A horizontal swipe past 50px, or a flick faster than 0.3px/ms, steps to the next or previous image. It uses pointer events and no dependency.
  • renderImage. Pass next/image (or any <img>): renderImage={(image, { sizes }) => <Image src={image.src} alt={image.alt} width={image.width} height={image.height} sizes={sizes} />}. The default is a plain lazy <img>.
  • Text. The built-in English texts (Close, Previous image, ...) are replaceable through labels.
  • Empty. An empty images array renders nothing; the page owns the empty state.

Styles: install the stylesheets of the packages it composes along with this one (@behivetech/organisms.gallery/styles.css, plus atoms.modal, atoms.button, icons.base and atoms.spinner). It reads the design tokens from @behivetech/cms.base-styles.

Properties

Gallery props

PropTypeDefaultDescription
images*GalleryImage[]—The images, in the order the grid, Tab and the lightbox follow. An empty array renders nothing
classNamestring—Additional class names to merge with the component root element
columnsGalleryColumns{ xs: 2, md: 3, lg: 4, }Columns: a number for every width, or `{ xs, md, lg }` for below 48rem, from 48rem and from 64rem (any you omit keep the default). Up to 8. Defaults to `{ xs: 2, md: 3, lg: 4 }`.
labelsPartial<GalleryLabels>—Replaces any of the built-in English texts, for translation
onOpenChange((open: boolean, index: number) => void)—Called with `true` and the opened index when the lightbox opens, and with `false` and the index showing when it closes
renderImageGalleryRenderImage(image, { context }) => ( <img src={image.src} alt={image.alt} width={image.width} height={image.height} loading={context === "thumbnail" ? "lazy" : "eager"} decoding="async" /> )Draws each image, so an app can pass `next/image`. Receives the image and `{ context: "thumbnail" | "lightbox", sizes }`. Defaults to a lazy `<img>`. Render a plain `<img>` (or a component that renders one): the grid fills the tile with it.

Also accepts the native attributes of its root element (className, aria-*, event handlers, …).

Functions

useSwipe hook

Touch swipe with pointer events and no dependency. The image follows the finger by writing one custom property on the tracked element (re-rendering on every move would be wasteful); the stylesheet turns it into a transform. A swipe left goes next, right goes previous; at either end the drag rubber-bands and snaps back.

useSwipe(__0: UseSwipeOptions): { trackRef: RefObject<HTMLDivElement | null>; handlers: { onPointerDown: (event: SwipeEvent) => void; onPointerMove: (event: SwipeEvent) => void; onPointerUp: (event: SwipeEvent) => void; onPointerCancel: (event: SwipeEvent) => void; }; }
ParameterTypeDefaultDescription
__0UseSwipeOptions——

Returns { trackRef: RefObject<HTMLDivElement | null>; handlers: { onPointerDown: (event: SwipeEvent) => void; onPointerMove: (event: SwipeEvent) => void; onPointerUp: (event: SwipeEvent) => void; onPointerCancel: (event: SwipeEvent) => void; }; }

useStrandedFocus hook

A disabled button cannot keep focus, so when Previous or Next becomes disabled while holding it (or focus fell to the page), hand it to the other arrow. `rootRef` holds exactly those two buttons, in that order; Close sits outside it.

useStrandedFocus(rootRef: RefObject<HTMLElement | null>, canGoPrevious: boolean, canGoNext: boolean): void
ParameterTypeDefaultDescription
rootRefRefObject<HTMLElement | null>——
canGoPreviousboolean——
canGoNextboolean——

Returns void

useLightbox hook

Open state for the gallery lightbox, and where focus goes when it closes: the thumbnail of the image showing at that moment, not the one first opened, scrolled into view.

useLightbox(__0: UseLightboxOptions): { openIndex: number | null; open: (index: number) => void; changeIndex: (index: number) => void; close: () => void; restoreFocus: (event: Event) => void; registerThumbnail: (index: number) => (element: HTMLButtonElement | null) => void; }
ParameterTypeDefaultDescription
__0UseLightboxOptions——

Returns { openIndex: number | null; open: (index: number) => void; changeIndex: (index: number) => void; close: () => void; restoreFocus: (event: Event) => void; registerThumbnail: (index: number) => (element: HTMLButtonElement | null) => void; }

resolveColumns function

Resolves the `columns` prop to a whole count per breakpoint. A number applies everywhere; an object overrides the defaults per breakpoint.

resolveColumns(columns: GalleryColumns): Record<GalleryBreakpoint, number>
ParameterTypeDefaultDescription
columnsGalleryColumns——

Returns Record<GalleryBreakpoint, number>

rowSpan function

How many grid rows a tile spans, from its image's height over width. Tiles of different ratios then stack into columns with the grid's own auto-placement, while the DOM stays in `images[]` order.

rowSpan(__0: GalleryImage): number
ParameterTypeDefaultDescription
__0GalleryImage——

Returns number

thumbnailSizes function

The `sizes` string for a thumbnail, from the columns it will sit in (the gutter is ignored, so it errs a little large).

thumbnailSizes(columns: Record<GalleryBreakpoint, number>): string
ParameterTypeDefaultDescription
columnsRecord<GalleryBreakpoint, number>——

Returns string