Skip to content
DocsLayout enginesMasonry, justified & grid

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.

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 === containerWidth

A 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

EngineTimeMemory
MasonryO(n) · O(delta) appendO(n) positions
JustifiedO(n) · O(delta) appendO(n) positions
Fixed gridO(n)O(n) positions
Date-grouped / Sectioned / Uniform rows / RTLO(n)O(n) positions