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`.
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";23<Gallery4 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.
columnsis 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 inimages[]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. Passnext/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 throughlabels. - Empty. An empty
imagesarray 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.
Examples
Open all 3 →1const placeholder = (hue: number, width: number, height: number) =>2 `data:image/svg+xml;utf8,${encodeURIComponent(3 `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}"><rect width="100%" height="100%" fill="hsl(${hue} 80% 55%)"/></svg>`,4 )}`;56const SIZES = [7 [1200, 800],8 [800, 1200],9 [1000, 1000],10 [1200, 800],11 [800, 1000],12 [1200, 900],13 [900, 1200],14 [1200, 800],15] as const;1617const IMAGES: GalleryImage[] = SIZES.map(([width, height], index) => ({18 src: placeholder(index * 45, width, height),19 alt: `Photo ${index + 1}`,20 width,21 height,22 caption: index % 2 === 0 ? `Caption for photo ${index + 1}` : undefined,23}));2425const BasicGallery = () => <Gallery images={IMAGES} />;2627render(<BasicGallery />);
1const placeholder = (hue: number, width: number, height: number) =>2 `data:image/svg+xml;utf8,${encodeURIComponent(3 `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}"><rect width="100%" height="100%" fill="hsl(${hue} 80% 55%)"/></svg>`,4 )}`;56const SIZES = [7 [1200, 800],8 [800, 1200],9 [1000, 1000],10 [1200, 800],11 [800, 1000],12 [1200, 900],13 [900, 1200],14 [1200, 800],15] as const;1617const IMAGES: GalleryImage[] = SIZES.map(([width, height], index) => ({18 src: placeholder(index * 45, width, height),19 alt: `Photo ${index + 1}`,20 width,21 height,22 caption: index % 2 === 0 ? `Caption for photo ${index + 1}` : undefined,23}));2425const ThreeColumns = () => <Gallery images={IMAGES} columns={3} />;2627render(<ThreeColumns />);
Properties
Gallery props
| Prop | Type | Default | Description |
|---|---|---|---|
images* | GalleryImage[] | — | The images, in the order the grid, Tab and the lightbox follow. An empty array renders nothing |
className | string | — | Additional class names to merge with the component root element |
columns | GalleryColumns | {
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 }`. |
labels | Partial<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 |
renderImage | GalleryRenderImage | (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; }; }| Parameter | Type | Default | Description |
|---|---|---|---|
__0 | UseSwipeOptions | — | — |
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| Parameter | Type | Default | Description |
|---|---|---|---|
rootRef | RefObject<HTMLElement | null> | — | — |
canGoPrevious | boolean | — | — |
canGoNext | boolean | — | — |
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; }| Parameter | Type | Default | Description |
|---|---|---|---|
__0 | UseLightboxOptions | — | — |
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>| Parameter | Type | Default | Description |
|---|---|---|---|
columns | GalleryColumns | — | — |
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| Parameter | Type | Default | Description |
|---|---|---|---|
__0 | GalleryImage | — | — |
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| Parameter | Type | Default | Description |
|---|---|---|---|
columns | Record<GalleryBreakpoint, number> | — | — |
Returns string