# How Google Photos Lays Out Images in a Row

> A line-by-line breakdown of the greedy 'justified rows' algorithm behind Google Photos and Flickr, covering how to pack images of different aspect ratios into full-width rows of equal height in React and TypeScript.

By Vishwajeet Raj · 2026-06-28 · https://vishwajeet.co/blog/google-photos-justified-rows

---

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:

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

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

```text
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:

```text
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:

> _(interactive demo — view it on the live post)_

## The code

This is how it works:

```ts title="justify.ts"
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

```ts
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

```ts
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:

```ts
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:

```ts
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:

> _(interactive demo — view it on the live post)_

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:

```tsx title="JustifiedGallery.tsx"
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`](https://github.com/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](https://medium.com/google-design/google-photos-45b714dfbed1),
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.
