Skip to content
academia.sh

Lesson 10 / 19

Breakpoints and Grid

Turning breakpoints into tokens that carry a layout configuration, auditing column arithmetic at every breakpoint, detecting an orphaned breakpoint, and deriving bounds from content criteria.

Contents

Every token so far has been built on the assumption of a single viewport. Color roles are independent of width, and so are typography steps. Spacing and size tokens are not: the margin is 16 pixels on a narrow screen and 24 on a wide one, and the column count changes entirely.

A breakpoint states where this change happens. The Principle of Responsive Design lesson introduced a breakpoint as a media-query threshold; at the system level it is more than that. A breakpoint is not a single number, it is a record carrying every layout decision that holds past that threshold: column count, margin, gutter, and the container’s upper bound. This lesson turns that record into a token and audits the arithmetic between records.

A Breakpoint Carries a Configuration

The Grid Systems lesson showed that column width is a dependent quantity:

column=container2margin(n1)guttern\text{column} = \frac{\text{container} - 2 \cdot \text{margin} - (n - 1) \cdot \text{gutter}}{n}

The breakpoint set distributes this relation’s inputs across width ranges. The audit’s job is to show that the relation gives a meaningful result in every range and that the ranges are consistent with each other.

The audit seeks answers to five questions. Does column width come out as an integer? Do the margin and gutter values come from the spacing scale? Does a breakpoint carry a configuration different from the one before it? From which width are content criteria met? What is the container’s upper bound chosen against?

// breakpoint.mjs — auditing the breakpoint set with column arithmetic

const SPACING_SCALE = [4, 8, 12, 16, 20, 24, 32, 48, 64, 96];

// Every breakpoint carries a layout configuration. maxWidth null means the container is fluid.
const BREAKPOINTS = [
  { name: "narrow", min: 320, columns: 4, margin: 16, gutter: 16, maxWidth: null },
  { name: "medium", min: 640, columns: 8, margin: 24, gutter: 24, maxWidth: null },
  { name: "wide", min: 1024, columns: 12, margin: 24, gutter: 24, maxWidth: null },
  { name: "extra-wide", min: 1440, columns: 12, margin: 24, gutter: 24, maxWidth: 1200 },
  { name: "ultra", min: 1920, columns: 12, margin: 24, gutter: 24, maxWidth: 1200 },
];

const containerWidth = (bp, viewport) => (bp.maxWidth === null ? viewport : Math.min(viewport, bp.maxWidth));
const columnWidth = (container, bp) => (container - 2 * bp.margin - (bp.columns - 1) * bp.gutter) / bp.columns;
const span = (n, column, gutter) => n * column + (n - 1) * gutter;

console.log("breakpoint  min width  columns  margin  gutter  max width  container  column width  integer");
for (const bp of BREAKPOINTS) {
  const container = containerWidth(bp, bp.min);
  const s = columnWidth(container, bp);
  console.log(
    `${bp.name.padEnd(11)} ${String(bp.min).padStart(9)} ${String(bp.columns).padStart(7)} ${String(bp.margin).padStart(6)} ` +
      `${String(bp.gutter).padStart(6)} ${String(bp.maxWidth ?? "-").padStart(9)} ${String(container).padStart(9)} ` +
      `${s.toFixed(3).padStart(13)} ${(Number.isInteger(s) ? "yes" : "no").padStart(7)}`
  );
}

// Do the margin and gutter values come from the spacing scale?
console.log("\nbreakpoint  margin in scale  gutter in scale");
for (const bp of BREAKPOINTS) {
  console.log(
    `${bp.name.padEnd(11)} ${(SPACING_SCALE.includes(bp.margin) ? "yes" : "NO").padStart(15)} ` +
      `${(SPACING_SCALE.includes(bp.gutter) ? "yes" : "NO").padStart(15)}`
  );
}

// Orphaned breakpoint: carries the same layout configuration as the one before it.
const signature = (bp) => `${bp.columns}/${bp.margin}/${bp.gutter}/${bp.maxWidth}`;
console.log("\nbreakpoint  signature          same as previous");
for (let i = 0; i < BREAKPOINTS.length; i++) {
  const same = i > 0 && signature(BREAKPOINTS[i]) === signature(BREAKPOINTS[i - 1]);
  console.log(`${BREAKPOINTS[i].name.padEnd(11)} ${signature(BREAKPOINTS[i]).padEnd(18)} ${same ? "YES (orphaned)" : "no"}`);
}

// In the fluid range, at how many viewport widths does the column width come out as an integer?
console.log("\nbreakpoint  range             sample width  widths producing an integer");
for (const bp of BREAKPOINTS) {
  const upper = BREAKPOINTS[BREAKPOINTS.indexOf(bp) + 1]?.min ?? 2560;
  let integer = 0;
  let sample = 0;
  for (let w = bp.min; w < upper; w++) {
    sample++;
    if (Number.isInteger(columnWidth(containerWidth(bp, w), bp))) integer++;
  }
  console.log(
    `${bp.name.padEnd(11)} ${(bp.min + " - " + (upper - 1)).padEnd(17)} ${String(sample).padStart(14)} ` +
      `${(integer + "  (" + ((100 * integer) / sample).toFixed(1) + "%)").padStart(31)}`
  );
}

// Content criterion: the filter panel needs at least 270 px, the results list at least 480 px
// (values computed in the Grid Systems lesson).
const FILTER_MIN = 270;
const LIST_MIN = 480;
console.log("\nbreakpoint  split     filter (px)  list (px)  filter >=270  list >=480  decision");
for (const bp of BREAKPOINTS) {
  const container = containerWidth(bp, bp.min);
  const s = columnWidth(container, bp);
  let chosen = null;
  for (let a = 1; a < bp.columns; a++) {
    const filter = span(a, s, bp.gutter);
    const list = span(bp.columns - a, s, bp.gutter);
    if (filter >= FILTER_MIN && list >= LIST_MIN) {
      chosen = { a, filter, list };
      break;
    }
  }
  if (chosen) {
    console.log(
      `${bp.name.padEnd(11)} ${(chosen.a + "+" + (bp.columns - chosen.a)).padEnd(9)} ${chosen.filter.toFixed(1).padStart(11)} ` +
        `${chosen.list.toFixed(1).padStart(11)} ${"yes".padStart(13)} ${"yes".padStart(12)}  two columns`
    );
  } else {
    console.log(
      `${bp.name.padEnd(11)} ${"-".padEnd(9)} ${"-".padStart(11)} ${"-".padStart(11)} ${"-".padStart(13)} ${"-".padStart(12)}  one column`
    );
  }
}

// The smallest viewport width at which the two-column layout opens
const wide = BREAKPOINTS.find((bp) => bp.name === "wide");
let threshold = null;
for (let w = 320; w <= 1600; w++) {
  const s = columnWidth(containerWidth(wide, w), wide);
  const fits = [...Array(wide.columns - 1).keys()]
    .map((i) => i + 1)
    .some((a) => span(a, s, wide.gutter) >= FILTER_MIN && span(wide.columns - a, s, wide.gutter) >= LIST_MIN);
  if (fits) {
    threshold = w;
    break;
  }
}
console.log(`\nsmallest width at which the two-column layout opens with 12 columns: ${threshold} px`);
console.log(`wide breakpoint's minimum width: ${wide.min} px  ->  ${wide.min >= threshold ? "meets the content criterion" : "TOO EARLY"}`);

// Rationale for the max width: line length. At 16 px text, average character width is 7.005 px
// (measured in the Line Length and Spacing lesson); a 75-character upper bound is 525 px.
const CHARACTER_PX = 7.005;
console.log("\ncontainer  9-column span  characters  in 45-75 range");
for (const container of [1024, 1200, 1440, 1920]) {
  const bp = { columns: 12, margin: 24, gutter: 24 };
  const s = columnWidth(container, bp);
  const width = span(9, s, bp.gutter);
  const characters = width / CHARACTER_PX;
  console.log(
    `${String(container).padStart(9)} ${width.toFixed(1).padStart(14)} ${characters.toFixed(1).padStart(11)} ` +
      `${(characters <= 75 ? "yes" : "NO").padStart(16)}`
  );
}
breakpoint  min width  columns  margin  gutter  max width  container  column width  integer
narrow            320       4     16     16         -       320        60.000     yes
medium            640       8     24     24         -       640        53.000     yes
wide             1024      12     24     24         -      1024        59.333      no
extra-wide       1440      12     24     24      1200      1200        74.000     yes
ultra            1920      12     24     24      1200      1200        74.000     yes

breakpoint  margin in scale  gutter in scale
narrow                  yes             yes
medium                  yes             yes
wide                    yes             yes
extra-wide              yes             yes
ultra                   yes             yes

breakpoint  signature          same as previous
narrow      4/16/16/null       no
medium      8/24/24/null       no
wide        12/24/24/null      no
extra-wide  12/24/24/1200      no
ultra       12/24/24/1200      YES (orphaned)

breakpoint  range             sample width  widths producing an integer
narrow      320 - 639                    320                     80  (25.0%)
medium      640 - 1023                   384                     48  (12.5%)
wide        1024 - 1439                  416                      34  (8.2%)
extra-wide  1440 - 1919                  480                   480  (100.0%)
ultra       1920 - 2559                  640                   640  (100.0%)

breakpoint  split     filter (px)  list (px)  filter >=270  list >=480  decision
narrow      -                   -           -             -            -  one column
medium      -                   -           -             -            -  one column
wide        4+8             309.3       642.7           yes          yes  two columns
extra-wide  3+9             270.0       858.0           yes          yes  two columns
ultra       3+9             270.0       858.0           yes          yes  two columns

smallest width at which the two-column layout opens with 12 columns: 888 px
wide breakpoint's minimum width: 1024 px  ->  meets the content criterion

container  9-column span  characters  in 45-75 range
     1024          726.0       103.6               NO
     1200          858.0       122.5               NO
     1440         1038.0       148.2               NO
     1920         1398.0       199.6               NO

The Orphaned Breakpoint

The third table gives the most direct finding. The ultra breakpoint carries the same configuration as extra-wide: 12 columns, 24-pixel margin, 24-pixel gutter, 1200-pixel upper bound. No layout decision changes between the two.

A breakpoint like this produces no rule, only a name. Its cost is paid in three places: the style file carries one more media query, the test list includes one more width, and someone new tries to find out what changes at that width. An orphaned breakpoint is the layout-side counterpart of the orphaned token from the previous lessons, and it is removed the same way.

The number of breakpoints is not a target. Every breakpoint must change a layout decision; if it does not, it is not a breakpoint, it is a number.

Integer Columns Cannot Be a Rule

The fourth table measures the most commonly misunderstood side of layout arithmetic. In the fluid ranges, column width comes out as an integer very rarely: at 25 percent of widths in the narrow range, 12.5 percent in the medium range, and only 8.2 percent in the wide range.

In the two ranges where the container is fixed by the upper bound, the rate is 100 percent. The reason is clear: once the container is fixed, column width is fixed too, and only a single value is computed.

What follows is that integer column width cannot be a design target. In a fluid grid, column width changes continuously; forcing it to an integer means rounding the container to integer multiples, and that leaves uneven gaps at the edges. The right approach is to never write the column width at all: the grid is defined with fraction units, column width is computed at the paint stage, and alignment comes from the grid itself.

The calculation’s purpose becomes clear here too. Column width is computed not to be declared, but to test content criteria: how many pixels a block gets, and whether that is enough.

Breakpoints Are Derived from Content, Not Devices

The fifth and sixth blocks answer where breakpoint values should come from. The filter panel wants at least 270 pixels, the results list at least 480; these values were computed from content criteria in the Grid Systems lesson.

At the narrow and medium breakpoints, no division meets both criteria at once; the layout stays single-column. At the wide breakpoint, the 4+8 division works; at extra-wide, 3+9.

The sixth block shows a mismatch. With the 12-column configuration, the smallest width at which the two-column layout can open is 888 pixels; but the wide breakpoint’s minimum is 1024. Across the 136-pixel band between them, the layout stays single-column even though the content fits two columns.

This is a finding about where breakpoint values are chosen from. 1024 is not a content criterion, it is a device width; it comes from habit, not calculation. The value derived from content is 888, and rounding it to something close on the spacing scale gives 896. This is the layout-side counterpart of the five-year test: a breakpoint tied to device widths goes wrong once devices change; a breakpoint tied to a content criterion stays right as long as the content does not change.

The Upper Bound Is a Content Decision Too

The last table looks for the rationale behind the container’s upper bound and gives an unexpected result. The nine-column results-list area is 726 pixels wide in a 1024-pixel container, 858 at 1200, 1398 at 1920. With the average character width measured in the Line Length and Spacing lesson, that comes to 103.6, 122.5, and 199.6 characters respectively. All four are outside the 45–75 range.

This shows that the upper bound does not solve line length on its own. The 1200-pixel upper bound brings the results-list area down to 858 pixels, but 858 pixels still means a 122-character line.

The right reading is this: the container’s upper bound is the layout’s upper bound, not the text’s. The text’s upper bound is a separate token, and it is tied to a character count, not a column count. The summary paragraph in a record card uses its own measure limit, not the full nine columns. Confusing the two either narrows the layout needlessly or lets the text run too long to read.

Written Form

Breakpoints are defined as tokens, but they cannot be used directly in media-query conditions: a media-query condition cannot read a custom property. This is the second example of the boundary between the token system and style; the first was the contrast contract.

:root {
  --breakpoint-medium: 640px;
  --breakpoint-wide: 896px;
  --breakpoint-extra-wide: 1440px;

  --grid-columns: 4;
  --grid-margin: var(--spacing-4);
  --grid-gutter: var(--spacing-4);
  --grid-max-width: none;
}

@media (min-width: 640px) {
  :root {
    --grid-columns: 8;
    --grid-margin: var(--spacing-6);
    --grid-gutter: var(--spacing-6);
  }
}

@media (min-width: 896px) {
  :root { --grid-columns: 12; }
}

@media (min-width: 1440px) {
  :root { --grid-max-width: 1200px; }
}

.grid {
  display: grid;
  grid-template-columns: repeat(var(--grid-columns), 1fr);
  gap: var(--grid-gutter);
  padding-inline: var(--grid-margin);
  max-width: var(--grid-max-width);
  margin-inline: auto;
}

Because the numbers in the query conditions are written by hand, they carry the risk of drifting from the token values. The fix is a build step that generates query conditions from the token file: breakpoint values stay in a single source, and the style file is generated from it. The division of labor from the Preprocessors and Postprocessors lesson applies here — the query condition belongs to build time, the column count to runtime.

Note that column width appears nowhere. Because the grid is defined with fraction units, the width is computed at the paint stage; the calculation in this lesson was done not to produce a declaration, but to test content criteria.

Summary

  • A breakpoint is not a number, it is a configuration record carrying every layout decision that holds past that threshold: column count, margin, gutter, and the container’s upper bound.
  • A breakpoint carrying the same configuration as the one before it is orphaned; it produces no rule, only maintenance load, and it is removed.
  • In fluid ranges, column width rarely comes out as an integer — only 8.2 percent of widths in the wide range. Integer columns cannot be a design target; the grid is defined with fraction units. The calculation’s purpose is not to produce a declaration but to test content criteria.
  • Breakpoint values are derived from a content criterion, not a device width; in the measured set, the two-column layout can open at 888 pixels while the breakpoint sits at 1024.
  • The container’s upper bound is the layout’s bound, not the text’s; the text measure is a separate token tied to a character count.
  • Media-query conditions cannot read a custom property; breakpoint values are kept in a single source and query conditions are generated at build time.

Next Step

The foundation layer is complete here: color, typography, spacing, size, and layout decisions are all named and derivable. One gap remains. In the Color Tokens lesson, only eight of twenty roles had a dark-theme counterpart; twelve cells were empty. A system carrying more than one theme does not mean writing a separate value list for each theme, it means deriving the themes from a single rule. The next lesson builds that derivation: it produces the dark theme and a brand theme from the light theme, closes the missing-token report, and generates style output from the token source.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close