Layout engines
Three pure, O(n) algorithms. Each visits every image once and returns absolute positions.
Masonry (Pinterest)
Pinterest content is visually heterogeneous — every pin has a different aspect ratio. Masonry preserves each ratio and packs tightly by dropping the next image into the shortest column.
for (const image of images) {
const renderHeight = (image.height / image.width) * columnWidth;
const c = shortestColumn(heights); // scan of ≤ 6 columns → O(1)
place(image, x = c * (colW + gap), y = heights[c]);
heights[c] += renderHeight + gap;
}
containerHeight = max(heights) - gap;Incremental append. Infinite scroll continues from the prior column-height vector, so new
pages cost O(delta) and existing tiles never move:
const state = computeMasonryState(firstPage, width, { columns, gap });
const next = appendMasonry(state, newImages); // O(newImages)Column count is responsive: 2 / 3 / 4 / 6 at 640 / 1024 / 1536px.
Justified (Airbnb gallery)
Greedy row filling, then every image in a row is scaled to a shared height so the row width exactly equals the container — no gaps, no cropping, ratios preserved.
// For a row of k images with aspect ratios arᵢ:
rowHeight = (containerWidth - gap * (k - 1)) / Σ arᵢ;
widthᵢ = arᵢ * rowHeight; // Σ widthᵢ + gaps === containerWidthA row closes once scaling it to fill the width brings it to or under the target row height. The trailing row is filled only if it's close to target; otherwise it's left-aligned to avoid grotesquely large images.
Incremental append. computeJustifiedState / appendJustified keep the trailing row
open: rows that have closed stay byte-for-byte fixed, and appends re-run the greedy row
builder over only (open row + new items) in O(delta) — infinite scroll never reflows
visible tiles.
const state = computeJustifiedState(firstPage, width, { targetRowHeight, gap });
const next = appendJustified(state, newImages); // O(openRow + newImages)More engines (@repo/layout-engines)
Four further pure, O(n) algorithms covering grouped, regional, and RTL layouts. All accept
(images, containerWidth, options) and return the same LayoutResult — so the renderers and
virtualizer treat them exactly like the core three.
Date-grouped grid (Google Photos)
Uniform square tiles (no cropping, exact boxes → zero CLS), wrapped into rows and bucketed
by a groupKey (e.g. the YYYY-MM date). A header band is dropped below the previous group's
bottom, so groups read as distinct rows:
computeDateGrouped(images, width, {
groupKey: (img) => img.date.slice(0, 7),
tileSize: 200, gap: 8, headerHeight: 48,
});Sectioned masonry
Shortest-column masonry that snaps every column to the tallest column's baseline (plus a
header band) whenever sectionKey changes — each section begins on a clean horizontal line
while still packing tightly within itself.
Uniform-height rows
Every tile is scaled to one fixed rowHeight (aspect kept), flowing left→right and wrapping
on overflow or at a fixed tilesPerRow. Unlike justified, rows are not stretched — edges
stay ragged but dimensions are exact and jank-free to lay out.
RTL masonry
Identical placement order and column assignment to masonry, but the x-axis is mirrored: the
shortest column sits at the right edge, matching RTL reading order. Properties — shortest
column, O(delta) feel, container height — are identical; it's the horizontal reflection of the
LTR result (x = width − ltr.x − ltr.width).
Complexity
| Engine | Time | Memory |
|---|---|---|
| Masonry | O(n) · O(delta) append | O(n) positions |
| Justified | O(n) · O(delta) append | O(n) positions |
| Fixed grid | O(n) | O(n) positions |
| Date-grouped / Sectioned / Uniform rows / RTL | O(n) | O(n) positions |