How Google Photos Lays Out Images in a Row

You might have noticed that in Google Photos, no matter what an image’s aspect ratio is, it just fits neatly into a row, and the other images line up alongside it across the rows. No image looks taller than the others. There’s never a problem showing a portrait and a landscape image in the same row.

There’s an algorithm behind it.

It’s called the justified rows layout (sometimes “justified gallery”), and the same idea powers Flickr and Unsplash. The essence of the algorithm is only fifteen lines of math.

This is all about aspect ratio

The row of images has a definite width (the width of the container). We can define the height of a row, and having done that, the width of any image depends on the aspect ratio of the image:

aspect ratio (ar) = width / height
displayed width    = rowHeight × ar

So, the row of images having the same rowHeight occupies the following width:

totalWidth = rowHeight × (ar₁ + ar₂ + ... + arₙ) + gaps

Everything hinges on that sum of aspect ratios. Call it arSum. If we want a row to exactly fill the container, we just solve for the height:

containerWidth = rowHeight × arSum + totalGap
        ⟹  rowHeight = (containerWidth − totalGap) / arSum

Another way to see it: treat the whole row as a single image as wide as the container. Because the images sit close to each other, their aspect ratios add up to the aspect ratio of the row, so the only unknown left is the height, and that last formula hands it to you.

This step pretty much sums up the entire algorithm. All that is left to do now is decide which images go where in each row.

The Greedy Algorithm

The greedy algorithm is always the first step that we take because it makes sense to try and add pictures to our current row till it becomes “big enough” and then stretch it to the container width to begin the next row.

How “big” is it? We choose the desired row height, which may be 160 pixels, for example. We try to squeeze more pictures into our row till the row becomes larger than our container.

Watch it run

Here’s the algorithm playing out one photo at a time. Step through it or hit play, and watch the aspect-ratio sum climb until the projected width crosses the container width. At that point the row flushes, solves its height, and the photos snap into place:

Step-by-step visualization of a justified-rows photo layout. Photos are added to a row while the running sum of aspect ratios is tracked; when the projected width reaches the container width the row is flushed and its height is solved so it spans the full width, except the final row which keeps the target height. Currently Start with an empty row. We add photos one by one and keep a running sum of their aspect ratios.

step 1 / 12
Σ aspect ratios
0.00
projected width
0
container width
632
solved row height
-
Start with an empty row. We add photos one by one and keep a running sum of their aspect ratios.
Incoming photos
1.78
0.67
1.50
1.50
0.82
1.60
1.00
1.78
Building row (at target height 150px, overflow is clipped)
projectedW 0 / containerW 632
Finalized rows
No rows finalized yet.

The code

This is how it works:

const TARGET_ROW_HEIGHT = 160;
const GAP = 8;

interface PhotoBase {
  width: number;
  height: number;
}

type LaidOut<P> = P & { displayW: number; displayH: number };

export function justify<P extends PhotoBase>(
  photos: P[],
  containerW: number,
  targetH = TARGET_ROW_HEIGHT,
  gap = GAP,
): LaidOut<P>[][] {
  if (containerW <= 0) return [];

  const rows: LaidOut<P>[][] = [];
  let row: P[] = [];
  let arSum = 0;

  const flush = (isLast: boolean) => {
    if (row.length === 0) return;
    const totalGap = gap * (row.length - 1);
    const fitH = (containerW - totalGap) / arSum;
    // Don't over-stretch a lonely last row (matches Google's behaviour).
    const rowH = isLast ? Math.min(fitH, targetH) : fitH;
    rows.push(
      row.map((p) => {
        const ar = p.width / p.height;
        return { ...p, displayH: rowH, displayW: rowH * ar };
      }),
    );
    row = [];
    arSum = 0;
  };

  for (const p of photos) {
    row.push(p);
    arSum += p.width / p.height;
    const projectedW = targetH * arSum + gap * (row.length - 1);
    if (projectedW >= containerW) flush(false);
  }
  flush(true);

  return rows;
}

Let’s read it the way it runs.

Accumulating a row

for (const p of photos) {
  row.push(p);
  arSum += p.width / p.height;
  const projectedW = targetH * arSum + gap * (row.length - 1);
  if (projectedW >= containerW) flush(false);
}

We walk the photos in order. For each one we add it to the current row and add its aspect ratio to arSum. Then we ask: if this row were laid out at the target height, how wide would it be? That’s projectedW = targetH × arSum + gaps.

The instant projectedW reaches the container width, the row has enough images to fill it, so we flush. Note we flush including the image that tipped it over. That’s deliberate: it’s why rows end up slightly shorter than the target height (more images squeezed in) rather than taller. The row is already at-or-past the target width, so scaling it down to fit makes every image a touch smaller.

Flushing: snapping the row to the container

const totalGap = gap * (row.length - 1);
const fitH = (containerW - totalGap) / arSum;

This is the rearranged formula from earlier. Given the images we committed to this row, fitH is the exact height at which they fill the container edge to edge. It’ll usually be a bit under targetH, which is expected and correct.

Then we assign each image its final pixel size:

row.map((p) => {
  const ar = p.width / p.height;
  return { ...p, displayH: rowH, displayW: rowH * ar };
});

Same height for every image in the row, width derived from each one’s aspect ratio. Because we solved for fitH from arSum, these widths are guaranteed to add up to the container width.

The last-row trap

This is the part most people get wrong:

const rowH = isLast ? Math.min(fitH, targetH) : fitH;

The final row usually doesn’t have enough images to fill the container. If you apply the fitH formula to it, a last row with one wide image gets stretched to fill the full width, which looks wrong. Clamping the last row to min(fitH, targetH) keeps it at the target height and leaves it left-aligned with empty space on the right, the way Google Photos handles it.

Try it

Drag the sliders. Lowering the target height packs more (smaller) images per row. Raising it gives fewer, larger images. Every row stays perfectly flush to the container. Resize your browser window and the whole thing reflows, because the container width changed:

Real photos from Lorem Picsum. Every row stays flush to 0px — resize the window and watch it reflow.

These are actual photos that have different aspect ratios. One is a 2.69 panorama, another is a 0.66 portrait, but every row aligns perfectly. The badges reveal the aspect ratio for each photograph, while “Shuffle” will randomize the order in which the rows break.

Wiring it into React

The layout function is pure and framework-agnostic. It just turns { width, height }[] into positioned rows. To use it we need two things: the container’s measured width, and a re-run whenever that width changes. A ResizeObserver handles both:

import { useEffect, useMemo, useRef, useState } from "react";
import { justify } from "./justify";

interface Photo {
  src: string;
  width: number;
  height: number;
  alt?: string;
}

export function JustifiedGallery({
  photos,
  targetH = 160,
  gap = 8,
}: {
  photos: Photo[];
  targetH?: number;
  gap?: number;
}) {
  const ref = useRef<HTMLDivElement>(null);
  const [containerW, setContainerW] = useState(0);

  useEffect(() => {
    const el = ref.current;
    if (!el) return;
    const ro = new ResizeObserver(([entry]) => {
      setContainerW(entry.contentRect.width);
    });
    ro.observe(el);
    return () => ro.disconnect();
  }, []);

  const rows = useMemo(
    () => justify(photos, containerW, targetH, gap),
    [photos, containerW, targetH, gap],
  );

  return (
    <div ref={ref} className="flex flex-col" style={{ gap }}>
      {rows.map((row, ri) => (
        <div key={ri} className="flex" style={{ gap }}>
          {row.map((p) => (
            <img
              key={p.src}
              src={p.src}
              alt={p.alt ?? ""}
              width={p.displayW}
              height={p.displayH}
              loading="lazy"
              className="rounded-md object-cover"
              style={{ width: p.displayW, height: p.displayH }}
            />
          ))}
        </div>
      ))}
    </div>
  );
}

A few practical notes:

  • You must know the intrinsic dimensions up front. The algorithm needs each image’s width and height before the image loads in order to prevent layout jumps. Store them alongside your image URLs (most CMSes and image CDNs expose them).
  • Set width/height attributes, not just CSS, so the browser reserves the right box and your CLS stays at zero.
  • object-cover is a safety net. The math is exact, but sub-pixel rounding can leave a 1px sliver, and object-cover hides it without visibly cropping.

Where to go from here

This greedy version is what ships in plenty of production galleries because it’s O(n), predictable, and reflows instantly. Flickr open-sourced their production layout engine, flickr/justified-layout, and at its core it’s the same greedy row-filling you just read, with a few extra knobs (a tolerance band on row height, configurable min/max rows).

Google Photos started here too, then went further. According to the engineer who built its web grid, it ultimately uses the optimal version, adapting Knuth’s line-breaking algorithm to choose the row breaks with the least overall distortion. For most galleries you can’t tell the difference, and greedy is a fraction of the code.

At Google Photos scale (hundreds of thousands of photos), the row math stays the easy part. The real work is virtualization (only laying out what’s near the viewport) and streaming (estimating a layout from metadata before the images load, then refining). Ship the fifteen lines. If you ever outgrow them, those are what you’ll reach for, not a cleverer row algorithm.