Components

Search results as on bilder.ntb.no: justified rows or a grid with captions, every row the same height. Large results are virtualized and load more as you scroll. The demo shows it on a full page.
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

NameTypeDefaultDescription
itemsT[] (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.
sizenumber220Justified: wanted row height. Grid: narrowest cell. In px.
captionHeightnumber72Grid only: px under the square for the caption.
gapnumber8Between tiles and rows, in px.
totalnumber–Hits in the whole result, when known. Decides virtualization before all pages are loaded.
virtualizeAbovenumber200Results up to this many render every row, without virtualization.
overscannumber3Rows kept rendered above and below the viewport when virtualized.
hasMorebooleanfalseWhether more pages can be loaded.
loadingMorebooleanfalseA page is loading: shows “Loading more…”.
onLoadMore() => void–Called once per item count when the end comes within a screen.
emptyReactNode–Shown instead of the grid when there are no items and none are loading.
scrollRefRefObject<HTMLElement | null>the windowThe element that scrolls, when it isn’t the window.

Tile

The second argument of renderItem.

NameTypeDefaultDescription
widthnumber–Tile width in px.
heightnumber–Row height in px, the same for every row.
indexnumber–Position in items.
priority'high' | 'eager' | 'lazy'–First row, rest of the first screen, everything else. Pass it to the tile.

JustifiedTile, GridCell

NameTypeDefaultDescription
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

NameTypeDefaultDescription
hrefstring–Where the tile goes: the details page (the preview, until there is one).
labelstring–The link’s accessible name.
childrenReactNode–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[]