Grids
Justified, masonry and uniform React photo grids that work on their own or open the lightbox, with gaps, columns and row heights per screen width.
The package has three optional grids. Each one works on its own: a grid without the lightbox is a responsive photo layout, and the lightbox keeps working with any markup you already have.
With the lightbox
import { useRef, useState } from "react";
import { JustifiedGrid, Lightbox, type GridHandle, type Slide } from "liftbox";
import "liftbox/styles.css";
export function Gallery({ slides }: { slides: Slide[] }) {
const [index, setIndex] = useState(-1);
const grid = useRef<GridHandle>(null);
return (
<>
<JustifiedGrid
ref={grid}
slides={slides}
onOpen={setIndex}
rowHeight={(width) => (width < 640 ? 200 : 320)}
/>
<Lightbox
slides={slides}
open={index >= 0}
index={Math.max(index, 0)}
onIndexChange={setIndex}
onClose={() => setIndex(-1)}
getThumbnail={(i) => grid.current?.getThumbnail(i)}
/>
</>
);
}getThumbnail from the grid's ref makes the lightbox fly out of the clicked photo and return focus to it on close.
Layouts
JustifiedGrid makes rows of equal height that fill the width edge to edge. Each image keeps its aspect ratio, and each row comes as close as it can to rowHeight.
Masonry and justified items are placed from each slide's width and height. A slide without them counts as square. The layout is known before any image loads, so nothing shifts. Uniform cells take their shape from aspectRatio.
Without the lightbox
Leave out onOpen and items become plain images with their alt text instead of buttons.
Responsive values
gap, rowHeight and columns take a number or a function of the grid's measured width:
<MasonryGrid slides={slides} columns={(width) => (width < 640 ? 2 : 4)} gap={12} />Each grid measures itself with one ResizeObserver. On the server, and before that first measurement, it lays out for defaultContainerWidth (1024px by default). Set it to the width the grid usually has, and the page does not reflow on load.
Images
The built-in image uses thumbnail, then poster, then the YouTube poster, then src for images, plus srcSet with sizes set to the rendered width, loading="lazy", decoding="async" and explicit width and height. Video and embed slides without a thumbnail or a poster get an empty frame instead of loading the video file. previewOf(slide) returns the same choice for your own markup.
To use a framework image component, return it from render.image. The item is positioned, so fill-style images work:
<JustifiedGrid
slides={slides}
onOpen={setIndex}
render={{
image: ({ slide, sizes }) => (
<Image fill src={slide.src} alt="" sizes={sizes} className="object-cover" />
),
}}
/>Return undefined to keep the built-in image for a slide.
Large galleries
Past 200 slides a grid renders only the items within a screen of the viewport and updates about once per screen of scrolling. The list gets data-virtual, and UniformGrid places its cells absolutely instead of using CSS grid. The item that opened the lightbox stays mounted, so focus returns to it on close. getThumbnail returns null for items that are not rendered, and the lightbox then opens and closes with its zoom and fade.
Grids are memoized. When the page re-renders, for example on every slide change with useLightboxHash, a grid skips the work as long as slides, onOpen and the layout props keep their identity. Define slides outside the component or with useMemo.
Props
Every grid takes these:
Prop
Type
JustifiedGrid
Prop
Type
MasonryGrid
Prop
Type
UniformGrid
Prop
Type
render.image
Prop
Type
Labels
labels.open(index, total, slide) names each button. It defaults to Open {alt}, then Open {title}, then Open image {n}, with a 1-based n. defaultGridLabels is exported.
Styling
These variables are read with fallbacks, so set them on the grid, on any ancestor or on :root:
| Variable | Default | Used for |
|---|---|---|
--eg-grid-radius | 4px | Image and focus ring corners |
--eg-grid-background | rgb(127 127 127 / 0.15) | Behind images while they load |
--eg-grid-focus-color | currentColor | Focus ring |
--eg-grid-focus-width | 2px | Focus ring |
--eg-grid-focus-offset | 2px | Focus ring |
--eg-grid-hover-opacity | 0.85 | Image under the pointer |
--eg-grid-cursor | zoom-in | Buttons |
--eg-grid-duration | 200ms | Hover and filter transitions |
Classes: eg-grid, eg-grid--masonry, eg-grid--justified, eg-grid--uniform, eg-grid-item, eg-grid-button and eg-grid-image. Large grids also get data-virtual on the list. classNames adds your own to root, item, button and image.
Server rendering and CSP
Grids render on the server with their item positions as style attributes. A Content Security Policy without 'unsafe-inline' in style-src blocks those until the client takes over. See Content Security Policy for the options.