Skip to content
academia.sh

Lesson 03 / 23

The Grid Model

Establishing a grid formatting context, the distinction between track and line, the fractional unit dividing free space, the hidden lower bound the min-content size places on a share, and naming areas.

Contents

Flexbox distributes on a single axis: it shares out in one direction and only aligns in the other. That is enough for a filter bar. The measurement page as a whole needs regions aligned on two axes: the profile block, the measurement table, and the sidebar need to sit relative to each other in both row and column.

This lesson establishes the layout model that defines rows and columns ahead of time. In flexbox, placement arises from the items’ sizes; in grid, an empty line layout is defined first and items are placed into that layout.

The Grid Formatting Context

When display: grid is declared on a box, the box becomes a grid container; its direct children are placed as grid items. As with flexbox, the container itself stays a block box in flow, the children’s display gets blockified, and their margins do not collapse.

The distinction is in what is declared on the container. In flexbox, a single direction is declared; in grid, the track definition for both axes is written:

.station-layout {
  display: grid;
  grid-template-columns: 240px 2fr 1fr;
  grid-template-rows: auto 1fr;
  gap: 24px;
}

Track, Line, and Cell

Four concepts are kept separate from each other.

A grid track is the band between two adjacent lines: a column or a row. The declaration above defines three column tracks and two row tracks.

A grid line is the line separating tracks. Three column tracks produce four lines; lines are numbered from 1 starting at the beginning. The same lines are also referred to with negative numbers counting from the end: the last line is 1-1, the one before it 2-2. Extending an item to the end of the grid does not require knowing the track count; 1-1 is enough.

A grid cell is the intersection of a row track and a column track; the smallest unit in a grid.

A grid area is a rectangular region bounded by four lines; it can span one or more cells. An item’s placement is always an area.

Lines can be named, and the name is written inside square brackets, between the entries of the track definition:

.station-layout {
  display: grid;
  grid-template-columns: [edge-start] 240px [content-start] 2fr 1fr [edge-end];
}

A named line can be used instead of a number, and it makes rewriting placement declarations unnecessary when the track count changes.

The Fractional Unit Divides Free Space

fr is a unit specific to grid: the fractional unit. It is not a length; it says how many shares the remaining space divides into once fixed tracks and gaps are subtracted.

// track.mjs — how the fr unit divides free space, and its hidden lower bound
const GAP = 24;

// track definition: fixed lengths are set aside first, the rest divides into fr shares
const tracks = [
  { notation: "240px", fixed: 240, fr: 0 },
  { notation: "2fr", fixed: 0, fr: 2 },
  { notation: "1fr", fixed: 0, fr: 1 },
];

const gapTotal = GAP * (tracks.length - 1);
const fixedTotal = tracks.reduce((t, s) => t + s.fixed, 0);
const frTotal = tracks.reduce((t, s) => t + s.fr, 0);

function shares(container) {
  const free = container - fixedTotal - gapTotal;
  return tracks.map((s) => s.fixed + (s.fr / frTotal) * free);
}

const CONTAINER = 960;
console.log(`grid-template-columns: ${tracks.map((s) => s.notation).join(" ")}   gap: ${GAP}px   container: ${CONTAINER}px`);
console.log(`fixed=${fixedTotal}  gap=${gapTotal}  free space to divide=${CONTAINER - fixedTotal - gapTotal}  fr total=${frTotal}`);
shares(CONTAINER).forEach((o, i) => console.log(`  ${tracks[i].notation.padEnd(6)} -> ${o.toFixed(1)}px`));

console.log("\n--- fr is a share of the free space, not of the container ---");
for (const extraFixed of [0, 240, 480]) {
  const one = (CONTAINER - extraFixed - gapTotal) / 3;
  console.log(`fixed track ${String(extraFixed).padStart(3)}px -> 1fr = ${one.toFixed(1)}px  (2fr = ${(2 * one).toFixed(1)}px)`);
}

// in a narrow container, the fr share drops below the track's min-content size
const NARROW = 600;
const minContent = [90, 180, 160];   // each track's longest unbreakable content
console.log(`\n--- narrow container (${NARROW}px): 1fr = minmax(auto, 1fr) ---`);
let total = 0;
shares(NARROW).forEach((share, i) => {
  const min = tracks[i].fr > 0 ? minContent[i] : 0;
  const applied = Math.max(share, min);
  total += applied;
  console.log(`  ${tracks[i].notation.padEnd(6)} share=${share.toFixed(1).padStart(6)}  min content=${String(min).padStart(3)}  applied=${applied.toFixed(1).padStart(6)}`);
});
console.log(`  tracks+gap = ${(total + gapTotal).toFixed(1)}px, container = ${NARROW}px, overflow = ${(total + gapTotal - NARROW).toFixed(1)}px`);

console.log(`\n--- same container with minmax(0, 1fr) ---`);
let total2 = 0;
shares(NARROW).forEach((share, i) => {
  total2 += share;
  const name = tracks[i].fr > 0 ? `minmax(0, ${tracks[i].fr}fr)` : tracks[i].notation;
  console.log(`  ${name.padEnd(16)} applied=${share.toFixed(1).padStart(6)}`);
});
console.log(`  tracks+gap = ${(total2 + gapTotal).toFixed(1)}px, overflow = ${(total2 + gapTotal - NARROW).toFixed(1)}px`);
grid-template-columns: 240px 2fr 1fr   gap: 24px   container: 960px
fixed=240  gap=48  free space to divide=672  fr total=3
  240px  -> 240.0px
  2fr    -> 448.0px
  1fr    -> 224.0px

--- fr is a share of the free space, not of the container ---
fixed track   0px -> 1fr = 304.0px  (2fr = 608.0px)
fixed track 240px -> 1fr = 224.0px  (2fr = 448.0px)
fixed track 480px -> 1fr = 144.0px  (2fr = 288.0px)

--- narrow container (600px): 1fr = minmax(auto, 1fr) ---
  240px  share= 240.0  min content=  0  applied= 240.0
  2fr    share= 208.0  min content=180  applied= 208.0
  1fr    share= 104.0  min content=160  applied= 160.0
  tracks+gap = 656.0px, container = 600px, overflow = 56.0px

--- same container with minmax(0, 1fr) ---
  240px            applied= 240.0
  minmax(0, 2fr)   applied= 208.0
  minmax(0, 1fr)   applied= 104.0
  tracks+gap = 600.0px, overflow = 0.0px

The first block shows the calculation’s order: from a 960-unit container, the fixed track (240) and two gaps (48) are subtracted first; the remaining 672 units divide into three shares. 2fr takes two shares (448), 1fr one share (224).

The second block corrects a commonly made reading mistake. 1fr is not one-third of the container; it is one-third of the free space. As the fixed track grows, the same 1fr declaration’s value drops from 304 to 144. The track definition itself has not changed; what changed is the dividend.

If the share total is less than one, not all of the free space is distributed: 0.5fr 0.5fr uses half the free space, the rest sits at the end of the grid. This behavior is consistent with the same declaration’s counterpart in flexbox.

The Share’s Hidden Lower Bound

The third block shows the most common cause of overflow in grid definitions. When the container drops to 600 units, the last track’s share computes to 104 units, but there is 160 units’ worth of unbreakable content inside the track — a long measurement code, or a table. The track stops at 160, and the total exceeds the container by 56 units.

The reason is this: 1fr, in its full expansion, means minmax(auto, 1fr). When the lower bound is auto, the track cannot drop below its content’s min-content size. If the share falls below this bound, the bound wins.

The fourth block gives the fix: if the lower bound is explicitly pulled to zero, the track follows the share and the overflow disappears. The cost is that content can now overflow inside the track; overflow management is handled separately in that track, with the declarations established in the Visual Presentation with CSS course.

This is grid’s counterpart to the min-width: auto constraint in flexbox. In both models, the source of the problem is the same: when the content size is larger than the computed share, the constraint wins.

Repetition and Gap

Instead of repeating the same track definition by hand, repeat() is written. What is interesting is that the repeat count can be given not as a number but as one of two keywords: auto-fill and auto-fit. Both produce the most tracks that fit the container; the distinction is in what happens when the produced tracks are empty.

// repetition.mjs — track count produced by auto-fill and auto-fit
const MIN = 220;   // minmax(220px, 1fr)
const GAP = 24;
const ITEM_COUNT = 5;

// most tracks that fit the container: n*min + (n-1)*gap <= width
function trackCount(width) {
  const n = Math.floor((width + GAP) / (MIN + GAP));
  return Math.max(1, n);
}

const size = (width, n) => (width - GAP * (n - 1)) / n;

console.log("repeat(auto-fill | auto-fit, minmax(220px, 1fr))   gap: 24px   item count: 5");
console.log("width     tracks  auto-fill size   filled tracks  auto-fit size");
for (const w of [320, 500, 760, 1000, 1240, 1600]) {
  const n = trackCount(w);
  const filled = Math.min(n, ITEM_COUNT);
  const fill = size(w, n);
  const fit = size(w, filled);
  console.log(
    String(w).padStart(8) +
    String(n).padStart(7) +
    (fill.toFixed(1) + "px").padStart(16) +
    String(filled).padStart(12) +
    (fit.toFixed(1) + "px").padStart(15)
  );
}

console.log("\n--- boundary: exact widths where the track count increases ---");
for (let n = 1; n <= 6; n++) {
  const threshold = n * MIN + (n - 1) * GAP;
  console.log(`minimum width for ${n} track${n === 1 ? "" : "s"}: ${threshold}px`);
}

console.log("\n--- same calculation with a fixed track count: repeat(4, minmax(220px, 1fr)) ---");
for (const w of [500, 760, 1000]) {
  const n = 4;
  const share = size(w, n);
  const applied = Math.max(share, MIN);
  const overflow = applied * n + GAP * (n - 1) - w;
  console.log(`width ${String(w).padStart(4)} -> share ${share.toFixed(1)}px, applied ${applied.toFixed(1)}px, overflow ${overflow.toFixed(1)}px`);
}
repeat(auto-fill | auto-fit, minmax(220px, 1fr))   gap: 24px   item count: 5
width     tracks  auto-fill size   filled tracks  auto-fit size
     320      1         320.0px           1        320.0px
     500      2         238.0px           2        238.0px
     760      3         237.3px           3        237.3px
    1000      4         232.0px           4        232.0px
    1240      5         228.8px           5        228.8px
    1600      6         246.7px           5        300.8px

--- boundary: exact widths where the track count increases ---
minimum width for 1 track: 220px
minimum width for 2 tracks: 464px
minimum width for 3 tracks: 708px
minimum width for 4 tracks: 952px
minimum width for 5 tracks: 1196px
minimum width for 6 tracks: 1440px

--- same calculation with a fixed track count: repeat(4, minmax(220px, 1fr)) ---
width  500 -> share 107.0px, applied 220.0px, overflow 452.0px
width  760 -> share 172.0px, applied 220.0px, overflow 192.0px
width 1000 -> share 232.0px, applied 232.0px, overflow 0.0px

minmax(a, b) gives a track’s lower and upper bound: the track does not drop below a, does not rise above b. minmax(220px, 1fr) means “at least 220 units, take the rest as a share,” and the repeat count is derived from this lower bound.

The track count follows directly: the minimum width needed for nn tracks is n×220+(n1)×24n \times 220 + (n-1) \times 24 units, and the count grows as the container exceeds this value. The second block gives these thresholds; the numbers in the first block line up exactly with them — a 1000-unit container gets four tracks, because the fourth threshold is 952 and the fifth is 1196.

The distinction appears in the last line. A 1600-unit container is wide enough for six tracks, but there are only five items to place. auto-fill keeps the sixth track empty; items stay at 246.7 units and an empty column remains on the right. auto-fit collapses the empty track; the track and its gap disappear, the five items share the remaining space and rise to 300.8 units.

The selection criterion is content-related. If card width should stay consistent and items change across different pages, auto-fill is correct: the same card is the same width on every page. If items should fill the container in every case, auto-fit is correct.

The third block shows why a fixed repeat count overflows on narrow screens: repeat(4, …) unconditionally produces four tracks, holds the lower bound at 220 units, and gives 452 units of overflow in a 500-unit container. The repeat keywords read this condition from the container; they adapt the track count without needing the media queries covered in the next topic.

Naming Areas

The track definition establishes the grid; where items fall is a separate declaration. The most readable way is to name areas and draw the placement as text in the container:

/* layout.css — step 3: page grid */
.station-layout {
  display: grid;
  grid-template-columns: minmax(0, 2fr) minmax(0, 1fr);
  grid-template-rows: auto auto 1fr;
  grid-template-areas:
    "profile      profile"
    "measurements sidebar"
    "location     sidebar";
  gap: var(--spacing-1);
}

.profile             { grid-area: profile; }
.measurement-section { grid-area: measurements; }
.location-section    { grid-area: location; }
.sidebar              { grid-area: sidebar; }

Each row of the string corresponds to a row track, each column to a column track. The same name appearing in two cells says the area spans those cells; sidebar extends across two rows. Writing a dot instead of a name leaves the cell empty.

This notation’s value is that the layout becomes visible in the style file: the alignment of the lines draws the grid itself. An item’s order in the document and its position in the grid are independent of each other — this distinction’s accessibility consequences are covered in the next lesson.

Summary

  • display: grid takes a track definition on two axes; a track is the band between two lines, a cell is the intersection of two tracks, and an area is the rectangular region bounded by four lines.
  • Lines are numbered from 1 at the start and 1-1 from the end; they can be tied to a name, and a name keeps placement declarations intact when the track count changes.
  • fr is not a length but a fractional unit: it divides the space remaining after fixed tracks and gaps are subtracted, so it gives a ratio of the free space, not the container.
  • Because 1fr expands to minmax(auto, 1fr), a track does not drop below its min-content size; to prevent overflow, the lower bound is explicitly zeroed with minmax(0, 1fr).
  • repeat(auto-fill, …) produces every track that fits and keeps empty ones, auto-fit collapses empty tracks and distributes the space to the remaining items.

Next Step

The track definition established the grid, and named areas drew the placement. But where do items with no area declaration fall? What happens if the container has more items than are defined? The next lesson covers the declarations that place items explicitly by line and the automatic algorithm that places the leftover ones.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close