Lesson 06 / 23
Multi-Column Layout
Splitting a text flow into columns, resolving column-count and column-width declarations together, the balancing rule, break control, and the cases multi-column layout is not suited to.
Contents
Every layout up to this point has had a box as its unit: a card, a section, a button. Boxes were placed onto lines or distributed along an axis. A long description paragraph on a page, though, is a single box, and on a wide container it stretches out as a single run of lines.
This lesson covers the model that splits the text itself. Multi-column layout divides a box’s content across more than one column; it does not split the box, it splits the flow. The distinction looks small, but it determines the layout’s entire behavior.
Splitting the Flow, Not the Box
When columns is declared on a box, the box itself stays a single box. Its border,
background, and margin apply to the whole. What changes is how the lines inside the box
are placed: lines fill the first column, and once it is full, move to the second.
This differs from flexbox and grid in two ways. First, columns are not items; they cannot be targeted with a selector, and their count is not visible in the document. Second, a single paragraph can be split across two columns: the text’s first three lines can sit in the first column, its remaining two lines in the second. In grid, an item is not split across two tracks, it spans them; here, the content genuinely fragments.
For this reason, multi-column layout is not a page-layout tool. Grid exists for placing page sections side by side; multi-column layout is used to keep a single continuous run of text at a readable width.
Count or Width
There are two declarations, and both feed the same calculation. column-count says how
many columns there will be, column-width says how wide a column will be at minimum.
The columns shorthand writes both together.
Whichever is written, the used column count and the used column width are derived by a single rule.
// column.mjs — used column count and width in a multi-column layout // CSS Multi-column pseudo-algorithm: whichever of count and width is written, // the other is derived from the container's width. const GAP = 32; // column-gap: 2rem (root 16px) function resolve(W, { width, count }) { let usedCount; if (count === "auto") { usedCount = Math.max(1, Math.floor((W + GAP) / (width + GAP))); } else if (width === "auto") { usedCount = count; } else { usedCount = Math.min(count, Math.max(1, Math.floor((W + GAP) / (width + GAP)))); } const usedWidth = (W - GAP * (usedCount - 1)) / usedCount; return { count: usedCount, width: usedWidth }; } const cases = [ ["column-width: 18rem", { width: 288, count: "auto" }], ["column-count: 3", { width: "auto", count: 3 }], ["columns: 18rem 3", { width: 288, count: 3 }], ]; console.log(`column-gap: 2rem = ${GAP}px (18rem = 288px, root 16px)`); for (const [name, declaration] of cases) { console.log(`\n--- ${name} ---`); console.log("container columns column width"); for (const W of [320, 480, 640, 800, 1024, 1280]) { const { count, width } = resolve(W, declaration); console.log( `${String(W).padStart(9)} ${String(count).padStart(7)} ${width.toFixed(1).padStart(12)}`, ); } } // column-width is a lower bound: with n columns produced, the used width // stays in [U, ((n+1)U + G)/n). The range narrows as n grows. console.log("\n--- column-width is a lower bound: the range of the used width ---"); console.log("U = 288px (18rem), G = 32px"); console.log("columns narrowest widest (approaching the bound) ratio"); for (let n = 1; n <= 5; n++) { const U = 288; const narrowest = U; const widest = ((n + 1) * U + GAP) / n; console.log( `${String(n).padStart(7)} ${narrowest.toFixed(1).padStart(9)} ` + `${widest.toFixed(1).padStart(28)} ${(widest / narrowest).toFixed(2).padStart(4)}`, ); }
column-gap: 2rem = 32px (18rem = 288px, root 16px)
--- column-width: 18rem ---
container columns column width
320 1 320.0
480 1 480.0
640 2 304.0
800 2 384.0
1024 3 320.0
1280 4 296.0
--- column-count: 3 ---
container columns column width
320 3 85.3
480 3 138.7
640 3 192.0
800 3 245.3
1024 3 320.0
1280 3 405.3
--- columns: 18rem 3 ---
container columns column width
320 1 320.0
480 1 480.0
640 2 304.0
800 2 384.0
1024 3 320.0
1280 3 405.3
--- column-width is a lower bound: the range of the used width ---
U = 288px (18rem), G = 32px
columns narrowest widest (approaching the bound) ratio
1 288.0 608.0 2.11
2 288.0 448.0 1.56
3 288.0 394.7 1.37
4 288.0 368.0 1.28
5 288.0 352.0 1.22
Three declaration forms, three separate behaviors.
When column-width is written on its own, the column count is derived from the container
and has no upper bound: as the container widens, columns keep being added. If five or six
columns forming on a very wide screen is not wanted, this declaration alone is not enough.
When column-count is written on its own, the column count is fixed, and as the container
narrows, the columns shrink together. The output’s second block shows the result: in a
320-unit container, three columns, each 85 units. A column fits about ten characters; the
text becomes unreadable. This declaration cannot be used alone to adapt to a narrow
container.
columns: 18rem 3 combines the two and states: “at most three columns, but every column
should be at least 18 base units wide.” The column count is the smaller of the two
conditions; the width condition governs in a narrow container, the count condition in a
wide one. In practice, this is usually the behavior that is wanted.
The last block puts a frequently misunderstood point into numbers. column-width is not a
target, it is a lower bound. As long as the container is wider than 18 base units, the
used width does not drop below this value, but it can rise above it: in the single-column
case, to more than double. This ratio narrows as the column count rises, because a new
column gets added sooner. For this reason, a column-width value is chosen against the
narrowest acceptable size, not against the ideal size for reading.
When the container is narrower than the column-width value, the lower bound cannot be
applied. The column count drops to one and the width equals the container; no overflow
occurs.
How Content Divides Into Columns
Once the columns’ size is settled, the second question comes up: which line goes into
which column? column-fill declares this. The balance value tries to equalize column
heights, the auto value fills columns in order and requires the box to have been given a
height for that.
Balancing is a simple split only when there are no unbreakable blocks. Once unbreakable blocks enter, the result changes.
// balance.mjs — column-balancing model: at what height content splits across columns // The unit of measure is a line. A block is "unbreakable" if break-inside: avoid is written. const COLUMNS = 3; const content = [ { name: "intro-p", lines: 6, breakable: true }, { name: "method-p", lines: 5, breakable: true }, { name: "measurement-list", lines: 7, breakable: false }, { name: "interim-p", lines: 4, breakable: true }, { name: "figure", lines: 9, breakable: false }, { name: "result-p", lines: 8, breakable: true }, { name: "note-p", lines: 3, breakable: true }, ]; // Distributes content into columns in order, given a column height. function pack(H) { const columns = [[]]; let filled = 0; const newColumn = () => { columns.push([]); filled = 0; }; for (const block of content) { if (!block.breakable) { if (filled > 0 && filled + block.lines > H) newColumn(); columns[columns.length - 1].push(`${block.name}(${block.lines})`); filled += block.lines; continue; } let remaining = block.lines; let part = 0; while (remaining > 0) { if (filled >= H) newColumn(); const placed = Math.min(remaining, H - filled); part += 1; const label = remaining === block.lines && placed === block.lines ? `${block.name}(${placed})` : `${block.name}.${part}(${placed})`; columns[columns.length - 1].push(label); filled += placed; remaining -= placed; } } const heights = columns.map((c) => c.reduce((t, e) => t + Number(e.match(/\((\d+)\)/)[1]), 0)); return { columns, heights }; } const total = content.reduce((t, b) => t + b.lines, 0); console.log(`total ${total} lines, ${COLUMNS} columns, even split ${(total / COLUMNS).toFixed(2)} lines`); console.log("unbreakable blocks: " + content.filter((b) => !b.breakable).map((b) => `${b.name}(${b.lines})`).join(", ")); // column-fill: balance — searches for the smallest height that does not exceed the column count let H = Math.ceil(total / COLUMNS); let result = pack(H); while (result.columns.length > COLUMNS) { H += 1; result = pack(H); } console.log(`\n--- column-fill: balance (height found: ${H} lines) ---`); result.columns.forEach((c, i) => { console.log(`column ${i + 1} (${result.heights[i]} lines): ${c.join(" | ")}`); }); console.log(`highest - lowest difference: ${Math.max(...result.heights) - Math.min(...result.heights)} lines`); // column-fill: auto — height is given from outside, columns fill in order const FIXED = 12; const auto = pack(FIXED); console.log(`\n--- column-fill: auto, block-size: ${FIXED} lines ---`); auto.columns.forEach((c, i) => { const overflow = i >= COLUMNS ? " <- exceeded the defined column count" : ""; console.log(`column ${i + 1} (${auto.heights[i]} lines): ${c.join(" | ")}${overflow}`); }); // how much does balancing improve if the unbreakable blocks are removed const oldContent = content.map((b) => ({ ...b })); for (const b of content) b.breakable = true; let H2 = Math.ceil(total / COLUMNS); let r2 = pack(H2); while (r2.columns.length > COLUMNS) { H2 += 1; r2 = pack(H2); } console.log(`\n--- same content, no block has break-inside: avoid ---`); console.log(`height found: ${H2} lines (with unbreakable blocks: ${H} lines)`); r2.columns.forEach((c, i) => { console.log(`column ${i + 1} (${r2.heights[i]} lines): ${c.join(" | ")}`); }); content.forEach((b, i) => { b.breakable = oldContent[i].breakable; });
total 42 lines, 3 columns, even split 14.00 lines unbreakable blocks: measurement-list(7), figure(9) --- column-fill: balance (height found: 18 lines) --- column 1 (18 lines): intro-p(6) | method-p(5) | measurement-list(7) column 2 (18 lines): interim-p(4) | figure(9) | result-p.1(5) column 3 (6 lines): result-p.2(3) | note-p(3) highest - lowest difference: 12 lines --- column-fill: auto, block-size: 12 lines --- column 1 (11 lines): intro-p(6) | method-p(5) column 2 (11 lines): measurement-list(7) | interim-p(4) column 3 (12 lines): figure(9) | result-p.1(3) column 4 (8 lines): result-p.2(5) | note-p(3) <- exceeded the defined column count --- same content, no block has break-inside: avoid --- height found: 14 lines (with unbreakable blocks: 18 lines) column 1 (14 lines): intro-p(6) | method-p(5) | measurement-list.1(3) column 2 (14 lines): measurement-list.2(4) | interim-p(4) | figure.1(6) column 3 (14 lines): figure.2(3) | result-p(8) | note-p(3)
This is a model, not a browser’s actual output: line counts are assumed, and the balancing search is written to find the smallest height. The constraint the model shows is real.
The third block gives the case where splitting is free: 42 lines divide into three columns as 14, 14, 14. There is no other result than an even split.
In the first block, two blocks are marked unbreakable. Balancing searches for the smallest height that can fit the content without opening a third column, and finds 18 instead of 14. The resulting split is 18, 18, 6; the last column is a third as full as the others. An unbreakable block that does not fit a gap smaller than itself jumps to the start of the next column and leaves unusable space behind it. Balancing cannot recover this space.
The second block shows the auto value. The height is given from outside, columns fill in
order, and the content does not fit three columns: a fourth column forms. Columns beyond
the defined count are called an overflow column and step outside the box’s boundary.
column-fill: auto is therefore only meaningful in boxes whose height is controlled.
Break Control
When text splits across columns, some splits are not wanted: a heading should not be left alone at the bottom of a column, a table should not be cut in half. The property family that declares this is break control.
break-inside: avoid prevents a box from being split from within. break-before: column
and break-after: column force a column break before or after a box. These declarations
work under the same names not only in multi-column layout but also in paginated output
(printing); both are contexts where content is divided into parts.
Two more declarations work at the line level. orphans gives the minimum number of lines
to be left at the end of a fragment, widows the minimum number of lines to appear at
the start of the next fragment. The default value for both is 2: a paragraph’s last
line does not fall alone at the start of a new column.
These declarations state a request, they do not guarantee it. The cost of the request
being honored was seen in the calculation above: as the number of unbreakable blocks
grows, balancing degrades. Break control is used selectively; writing avoid on every
block makes the columns unusable.
Rule and Spanning
Two more declarations complete the visual arrangement.
column-rule draws a line between columns, and its notation is the same as the border
shorthand: thickness, style, color. This line takes up no space; it is drawn at the
center of the column gap without entering the measurement. If the gap is narrower than the
rule, the rule overflows onto the columns.
column-span: all lets an item cut across every column. Such an item splits the flow in
two: the content before it is distributed into columns, the item sits at full width, and
the content after it is distributed into columns again. The two groups are balanced
separately. This is how a section heading is kept above the columns.
/* layout.css — step 6: station method notes */ .station-notes { columns: 18rem 3; column-gap: var(--spacing-1); column-rule: 1px solid var(--line); } .station-notes > h3 { column-span: all; margin-block-start: 0; } .station-notes figure, .station-notes table, .station-notes li { break-inside: avoid; } .station-notes p { orphans: 3; widows: 3; }
The declaration set is small, but every line has a separate reason. columns ties the
column count to the smaller of two conditions. column-rule makes the division visible
without interfering with the measurement. column-span: all keeps the heading above the
columns. break-inside: avoid is written only on the three item types whose splitting
would break meaning — it is not written on paragraphs, because a paragraph splitting is
normal. orphans and widows are pulled one above the default, ruling out one- and
two-line leftovers.
Where It Is Not Suited
Multi-column layout has a limit, and it comes from reading direction. Columns fill vertically: the reader goes down the first column, then returns to the top of the second. This is natural on paper, because the whole column is visible at once. On screen, if a column is longer than the viewport, the reader scrolls down, then scrolls back up, then moves to the next column.
The rule is: if a column’s height exceeds the viewport, multi-column layout makes reading harder. In short text — a method note, a definition list, a reference list — the gain is real; in a long article, it is a loss.
A second limit is that columns have no counterpart in the document. Screen readers and keyboard focus follow document order, and since columns do not change that order, no additional accessibility problem is created. But when a link inside a column receives focus, where the page scrolls to can be unpredictable, depending on column height.
Summary
- Multi-column layout splits the flow, not the box; a single paragraph can fragment across two columns, and columns are structures with no counterpart as an item in the document.
- The used column count is the smaller of the written
column-countvalue and the count that fits the container; the used width is found by dividing the remaining space by this count. column-widthis a lower bound, not a target: the used width can rise above this value, to more than double in the single-column case.column-fill: balancetries to equalize column heights; unbreakable blocks disrupt balancing and can leave the last column empty.column-span: allsplits the flow in two and the two parts are balanced separately;column-ruleis drawn in the column gap without taking up space.- If a column’s height exceeds the viewport, the reader is forced to scroll down and back up; this is the criterion for the layout not being suited.
Next Step
This lesson quietly used one criterion: when column width did not fit the container, the column count was reduced. The decision rested not on a list of screen widths, but on the narrowest measure at which content stays readable. The same reasoning applies to the page as a whole. The next topic carries this reasoning to the entirety of a layout: at what width should a layout change, by what measure is that decision found, and why is it derived from content rather than from screen lists?
To keep your progress and take notes, Log in
My notes
Log in to take notes.