---
title: 'Multi-Column Layout'
source: 'https://academia.sh/en/courses/layout-and-responsive-design/multi-column-layout'
course: 'Layout Systems and Responsive Design'
language: en
updated: '2026-08-17T18:09:25+00:00'
license: 'CC BY-SA 4.0'
---

# 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.

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.

```js
// 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.

```js
// 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.

```css
/* 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?
