Skip to content
academia.sh

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-count value and the count that fits the container; the used width is found by dividing the remaining space by this count.
  • column-width is 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: balance tries to equalize column heights; unbreakable blocks disrupt balancing and can leave the last column empty.
  • column-span: all splits the flow in two and the two parts are balanced separately; column-rule is 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.

Start typing to search.

↑↓ Esc navigate · open · close