Components
import { ImageGrid } from './-lib/image-grid'import { JustifiedTile, TileLink } from './-lib/tiles' <ImageGrid items={images} // each { id, ratio }, ratio = width / height renderItem={(image, tile) => ( <TileLink href={image.href} label={image.headline}> <JustifiedTile image={image} priority={tile.priority} /> </TileLink> )}/>Layouts
Both layouts give every row the same height, so the grid is a list of identical rows: row i starts at i × (row height + gap). Packing happens in aspect units, not pixels, so resizing the window only rescales the rows until a breakpoint is crossed.
Justified rows
Each row has one aspect ratio, the width divided by size, rounded to steps of 0,5. An image joins a row if that brings the row closer to its aspect; the row then fills the width, and tiles crop a little to absorb the difference.
What to check: every row is the same height and reaches both edges with 8 px gaps. Nothing looks stretched. Resize the window a little: rows scale, and images move to other rows only at certain widths.
<ImageGrid items={images} layout="justified" size={220} renderItem={…} />Grid with captions
As many columns as fit at size, stretched to the width. A cell is a square with the whole image fitted inside, plus captionHeight for a headline and three lines of description.
What to check: cells are equal; the space under captions is the same at every size.
<ImageGrid items={images} layout="grid" size={180} renderItem={(image, tile) => <GridCell image={image} priority={tile.priority} />}/>Size
One value for both layouts: the wanted row height in justified rows, the narrowest cell in the grid. The demo’s slider runs from 120 to 360 px.
What to check: at the smallest size images are still recognisable; at the largest there are still two or more per row.
<ImageGrid items={images} size={120} … /><ImageGrid items={images} layout="grid" size={360} … />Loading more
Give the grid hasMore and onLoadMore; it asks once per item count when the end comes within a screen. Two triggers make it reliable: the virtualizer reaching the last rows, and an IntersectionObserver on a sentinel after the last row.
What to check: scroll to the end of the box: “Loading more…” appears, and the next 12 images join without the ones above moving.
const [items, setItems] = useState(firstPage)const [loading, setLoading] = useState(false) <ImageGrid items={items} hasMore={items.length < total} loadingMore={loading} onLoadMore={() => { setLoading(true) fetchPage(items.length).then((page) => { setItems((all) => [...all, ...page]) setLoading(false) }) }} renderItem={…}/>Virtualization
Up to virtualizeAbove results (200 by default) every row renders, with content-visibility: auto so rows off screen aren’t painted. Above that only the rows near the viewport are in the DOM (TanStack Virtual). Pass total when the search knows it, so a large result is virtualized from the first page. The grid scrolls with the window unless you give it scrollRef, as in a dialog.
What to check: this box forces virtualization: only about a screen of rows plus three on either side are in the DOM while you scroll.
const scrollRef = useRef<HTMLDivElement>(null) <div ref={scrollRef} className="h-80 overflow-y-auto"> <ImageGrid items={items} total={search.total} virtualizeAbove={200} scrollRef={scrollRef} renderItem={…} /></div>Tiles and focus
The grid draws no tiles of its own. JustifiedTile and GridCell draw the image (and caption), with rounded corners. TileLink makes a tile focusable: on keyboard focus the image darkens slightly and a ring shows on top. The first row loads first, the rest of the first screen at once, the rest lazily (tile.priority).
What to check: press Tab into the example: each image darkens and gets a ring in turn.
renderItem={(image, tile) => ( <TileLink href={image.href} label={image.headline}> <JustifiedTile image={image} priority={tile.priority} /> </TileLink>)}Last row
A last row at least 80 % full is stretched like the others. A shorter one keeps each image at its own shape.
What to check: a few leftover images don’t blow up to fill the row; they stop short of the right edge.
One image
What to check: a single result sits at the left at its own shape and the normal row height.
Very wide and very tall images
Grey stand-ins with their shape; the fixtures have none this extreme.
What to check: a 10 : 1 panorama gets a row of its own, cropped to fit. Tall slivers (1 : 5) are narrow tiles, many to a row. Rows stay the same height. In the grid every shape gets the same cell.
No results
What to check: the page decides the message; the grid draws nothing of its own.
No images match. Try fewer filters.
<ImageGrid items={[]} empty={<p>No images match. Try fewer filters.</p>} … />Compared: React Aria
The same packing in React Aria’s GridList and Virtualizer, with im-web’s tile actions. It brings keyboard navigation, selection and screen-reader grid semantics, at a measurable cost. TanStack is the choice for now; see Performance.
What to check: hover a tile: a checkbox, Download and Add appear. Tab into the grid, move with the arrow keys, press Space to select.
API
ImageGrid
| Name | Type | Default | Description |
|---|---|---|---|
| items | T[] (T extends { id: string; ratio: number }) | – | The images, in order. ratio is width / height and decides the tile’s share of its row. |
| renderItem | (item: T, tile: Tile) => ReactNode | – | Draws one tile, at the size the grid gives it. |
| layout | 'justified' | 'grid' | 'justified' | Justified rows, or equal cells with captions. |
| size | number | 220 | Justified: wanted row height. Grid: narrowest cell. In px. |
| captionHeight | number | 72 | Grid only: px under the square for the caption. |
| gap | number | 8 | Between tiles and rows, in px. |
| total | number | – | Hits in the whole result, when known. Decides virtualization before all pages are loaded. |
| virtualizeAbove | number | 200 | Results up to this many render every row, without virtualization. |
| overscan | number | 3 | Rows kept rendered above and below the viewport when virtualized. |
| hasMore | boolean | false | Whether more pages can be loaded. |
| loadingMore | boolean | false | A page is loading: shows “Loading more…”. |
| onLoadMore | () => void | – | Called once per item count when the end comes within a screen. |
| empty | ReactNode | – | Shown instead of the grid when there are no items and none are loading. |
| scrollRef | RefObject<HTMLElement | null> | the window | The element that scrolls, when it isn’t the window. |
Tile
The second argument of renderItem.
| Name | Type | Default | Description |
|---|---|---|---|
| width | number | – | Tile width in px. |
| height | number | – | Row height in px, the same for every row. |
| index | number | – | Position in items. |
| priority | 'high' | 'eager' | 'lazy' | – | First row, rest of the first screen, everything else. Pass it to the tile. |
JustifiedTile, GridCell
| Name | Type | Default | Description |
|---|---|---|---|
| image | { src?, alt, headline?, description? } | – | The picture; GridCell also shows headline and description. |
| priority | 'high' | 'eager' | 'lazy' | 'lazy' | Sets loading and fetchpriority on the img. |
TileLink
| Name | Type | Default | Description |
|---|---|---|---|
| href | string | – | Where the tile goes: the details page (the preview, until there is one). |
| label | string | – | The link’s accessible name. |
| children | ReactNode | – | The tile. Darkens and gets a ring on keyboard focus. |
Layout functions
Plain functions in layout.ts, tested without a browser. Usable for any content with an aspect ratio.
rowAspect(width, size): number // row aspect in steps of 0,5justifiedRows(items, aspect): Row<T>[] // pack in aspect unitscolumnCount(width, size, gap): number // grid columns that fitgridRows(items, columns): Row<T>[] // rows of equal cellsrowHeight(layout, width, size, gap, captionHeight): numbertileWidths(row, layout, width, height, size, gap): number[]