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:
- wrapping only makes sense when the main axis is definite. Without a width to overflow, everything stays on one line — the same definiteness rule the sizing pass threads everywhere;
stretchfor align-content is not a distribution at all. Rather than spacing lines out, it grows each one equally to absorb the slack, so it has to be handled before the distribution rather than as another mode of it.
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.