Handing out slack

What justify actually computes, why growing items needs a loop, and packing a wrapped line.

In the code: engine/layout/base.py

A row of items rarely fills its container exactly. Everything interesting about flex layout is what happens to the difference.

Six ways to spend the leftover

Measure the items, add the gaps, subtract from the container: that is the slack. justify decides where it goes, and each mode is a one-line formula once you see it as how many gaps do we cut the slack into, and are the outer ones half size:

mode leading space between items
start 0 gap
center slack ÷ 2 gap
end slack gap
space-between 0 gap + slack ÷ (n−1)
space-around slack ÷ 2n gap + slack ÷ n
space-evenly slack ÷ (n+1) gap + slack ÷ (n+1)

The three space-* modes differ only in how the outer edges are treated: between gives them nothing, evenly gives them a full share, around gives each item half a share on each side — so of the slack, an outer edge gets half what an inner gap gets. That last one surprises people, and the table is the clearest way to say why.

Careful with that as a statement about the drawn margins: the declared gap lands only between items, never outside them, so with a non-zero gap the visible outer margin is smaller than half the visible inner one. The demo below sets gap="0" precisely so the ratios come out at the clean 0, ½, 1.

Note that the declared gap is a floor the slack is spread on top of, not something the space-* modes replace — which is why gap appears in every between-items formula above.

<diagram padding="14" width="300">
  <column gap="10" align="stretch">
    <row justify="space-between" gap="0"><rect>a</rect><rect>b</rect><rect>c</rect></row>
    <row justify="space-around" gap="0"><rect>a</rect><rect>b</rect><rect>c</rect></row>
    <row justify="space-evenly" gap="0"><rect>a</rect><rect>b</rect><rect>c</rect></row>
  </column>
</diagram>

Top row flush to both edges, middle row with half-width margins, bottom row with full ones — measured, the leading-to-inner ratios are exactly 0, ½ and 1.

The align="stretch" is doing real work there, and shows the precondition: there is only slack to distribute if the container has a size of its own. Without the stretch each row is merely as wide as its contents, the slack is nearly nothing, and all three modes come out looking the same. justify on a content-sized container is a no-op — not a bug, but it confuses everyone exactly once.

Growing items needs the same loop as grid tracks

grow is the other direction: instead of spacing items out, make them bigger. Sauvignon's grow is a flag rather than a number, so the growers split the free space equally — CSS's flex-grow: 2 weighting is the same algorithm with unequal weights, and the code takes a weight per item precisely so grid can use it too.

And it runs into exactly the problem grid track sizing does. An item cannot shrink below its min-content width, so a proportional share may be smaller than the item's own minimum — and clamping it there leaves the others dividing the wrong amount, which is how a grown row ends up wider than the container it was supposed to fit.

The answer is the same: freeze and repeat. Clamp the offending item, take it and its size out of the pool, and share what remains among the rest. Each pass freezes at least one item, so it terminates.

It is worth noticing that this is the same problem twice — distribute a quantity proportionally, subject to per-item bounds — so it is worth being the same code twice. CSS specifies it separately for flex (resolve-flexible-lengths) and grid (find-the-size-of-an-fr); here both call one share_with_floors, with grid weighting tracks by their fr factor and flex weighting items equally. The only difference left is what the floor means: for a track it is the track's own minimum, for a flex item it is the share needed to reach that minimum from its basis.

An explicit width is a basis, not a cap: an item sized 100px with a grow factor still grows past 100px. Getting that backwards makes grow look broken on exactly the items an author bothered to size.

Packing a wrapped line

With wrap, items that do not fit start a new line — and it is the same greedy first-fit as breaking text into lines, one level up. Fill the line until the next item would overflow, then begin another.

Then the whole thing happens again on the other axis. The lines have to be distributed down the container, which is align-content, and it reuses the very same distribution function: align-content is to wrapped lines what justify is to items. Recognising that saved a second implementation of the table above.

Two details that are easy to miss:

Further reading. css-flexbox-1 §9 is a genuinely readable specification — the resolve-flexible-lengths loop is the freezing algorithm above, written out with all the cases.