Dividing space that isn't there
How fr units resolve, why tracks freeze, and the reason CSS Grid's sizing algorithm is a loop rather than a division.
In the code: engine/layout/tracks.py
columns="1fr 2fr" looks like it should be a division: take the space, give a
third to one and two thirds to the other. It very nearly is — and the gap
between "very nearly" and "actually" is where the whole algorithm lives.
Free space first
fr is not a percentage of the container; it is a share of the leftover.
So the order is:
- Resolve every non-flexible track — fixed sizes, and content-based tracks which need a measure pass over the items in them.
- Subtract those, plus all the gaps, from the container.
- Divide what remains among the
frtracks in proportion to their factors.
That is why 200px 1fr and 1fr 1fr behave so differently in a cramped
container, and why gaps have to be subtracted before the division rather than
squeezed in afterwards.
<diagram padding="16">
<grid columns="1fr 2fr" gap="10" width="330">
<rect>1fr</rect>
<rect>2fr — twice as wide</rect>
<rect>1fr</rect>
<rect>2fr</rect>
</grid>
</diagram>
The width is load-bearing: fr divides leftover space, and a grid with no
width of its own has no leftover to divide, so the tracks fall back to their
content and the 2fr column can come out narrower than the 1fr one. Here the
tracks resolve to 106.7 and 213.3 — the promised 1:2.
The part that makes it a loop
A track cannot shrink below the content in it. So a proportional share may be smaller than the track's own minimum — and if you simply clamp it there, the other tracks are now dividing the wrong amount of free space.
The fix is iteration. CSS Grid calls it freezing:
repeat:
share out the remaining free space among the unfrozen fr tracks
if any track came out below its minimum:
set it to its minimum, mark it frozen,
remove it and its size from the pool, and go round again
else:
done
Each pass freezes at least one track, so it terminates in at most one round per track. The same shape of loop appears in flexbox, where it resolves items hitting their min or max size, and in table layout, which has been solving this since the 1990s.
Two edge cases fall out and both need handling explicitly:
- zero factors.
0frasks for none of the leftover, so it sizes to its min-content and never participates in the division. A naive implementation divides by a total factor of zero. - negative free space. When content already overflows, there is nothing to share. Every flexible track simply freezes at its minimum and the grid overflows — which is correct, and much better than distributing negative space and producing inverted boxes.
Named areas are a separate problem
Track sizing is arithmetic. Track placement — deciding which cell each item occupies — is a different algorithm sharing the same vocabulary. Sauvignon resolves three notations into one internal placement:
- auto-flow, filling cells in order;
- named areas, an ASCII-art template where each distinct name must form a solid rectangle (worth validating: a non-rectangular area is almost always a typo, and produces nonsense if you let it through);
- line numbers, including negative ones counting back from the end, so
-1is the final line — which is how you pin something to the last column without knowing how many there are.
All three end as the same (row-start, row-end, column-start, column-end)
tuple, and everything downstream only knows about that.
Further reading. css-grid-1 §12 ("Grid Sizing") is the canonical statement of the freezing loop, and it repays reading even if you never implement it — it is a careful piece of specification writing about a genuinely fiddly problem.