Foundations · 03

Layout & Grid

Two grids side by side: a conventional 12-column grid for content, and the brand's recursive subdivision grid for editorial surfaces.

Container

The design is built on a 1440 px frame with 80 px outer margins — 1280 px of content. The margin scales down to 20 px on mobile; the content width is capped.

TokenValueMeaning
--container-max1280 pxmaximum content width
--gutter20 → 80 pxouter margin, clamp(1.25rem, 5.5vw, 5rem)
--grid-columns12columns — the count
--grid-tracksrepeat(var(--grid-columns), minmax(0, 1fr))columns — the track list
--grid-gap24 pxcolumn gap
--section-gap64 → 160 pxpadding on each edge of a section — see Rhythm

The count and the track list are two tokens because they are two decisions. --grid-columns is how many; --grid-tracks is the grid-template-columns value built from it, with the floor that the intrinsic minimum requires. It is the same pair the subdivision grid keeps as --subdivide-count and --subdivide-cols, and it exists for the reason that one gives: a track set written in more than one place is a track set two rules can disagree about.

It had been written three times. .grid is the primitive, and components.css declared the identical repeat() twice more for .cf-values__frame and .cf-values__item — the pinned values section's own 12-column frame and the five items stacked inside it. Those two are not a component reinventing the page grid; they are the one place in the system that genuinely needs the page's column set inside a component, and they could not reach for .grid because they are set in an @supports block against markup that carries no utility class. The fix is not to move the markup. It is to give all three one declaration to read.

The gutter is proportional rather than stepped, and the proportion is not arbitrary: (1440 − 1280) ÷ 2 ÷ 1440 = 5.5556 %, rounded down to the 5.5vw in the token. Above 1440 the container caps and the outer margin grows instead of the content.

The cap lands the 1280, not the gutter. An earlier version of this paragraph said a ~5.5vw gutter "is exactly what makes the content column reach 1280 at the frame the design was measured on." It is not. At 1440 the gutter computes to 79.2 px, so the gutter alone would leave 1440 − 158.4 = 1281.6 px of content. What pins it to exactly 1280 is --container-max.container's max-width is calc(1280 + 2 × gutter), so the cap binds and the padding subtracts back to 1280 precisely. The gutter's job is to make the outer margin proportional; the cap's job is the content width.

The two also decouple at the top, which is the tell: 5.5vw does not reach 80 px until a viewport of 1454.5, while the content column reaches 1280 at 1438.2 (where v = 1280 + 0.11v). If the gutter were the mechanism those would be the same number. The exact gutter for a 1440 / 80 / 1280 frame is 5.5556vw; 5.5 is a deliberate rounding, and the tilde was doing honest work — the sentence around it was not.

Measured, so the correction does not overclaim in its turn: the cap starts binding between 1438 and 1439 (at 1438 the container is still the full viewport and content is 1279.82; at 1439 the container is 1438.28 and content is 1279.99). Content reaches a clean 1280.00 only from 1454 up, where the gutter has finished rounding to 80. So "exactly 1280 at the 1440 frame" is exact in the algebra and 1279.99 in device pixels — which is invisible, and still worth writing down on a page that keeps four decimals elsewhere.

Every .container is also a query container named layout. Components that behave differently at different widths ask it how much room they actually have, rather than asking the viewport — which cannot tell a card in the 1280 px content column from the same card in a 533 px documentation panel.

<section class="section">
  <div class="container">
    <div class="grid">
      <div class="col-6">…</div>
      <div class="col-6">…</div>
    </div>
  </div>
</section>

Landing a full-bleed element on the column

Most things sit inside a .container and inherit the column for free. A few cannot: the hero's CTA and its pause switch, the counter and the progress bar of the values section. Every one of them is drawn against a full-bleed box, so each has to name its own inset — and the obvious name for it, --gutter, is the wrong one.

--gutter is the outer margin while the container is still growing. Once the container hits --container-max it stops and centres, so the column moves inward while the gutter stays where it was. Past that crossover the two are different distances, and the gap between them opens without limit.

ViewportColumn edge--gutterAn element pinned to the gutter is off the column by
128070.470.40 — they coincide
14408079.20.8
192024080160
256056080480

--column-inset is that distance, and it is the token those elements take:

--column-inset: max(var(--gutter), (100% - var(--container-max)) / 2);

The crossover is exact rather than approximate, and for the same reason the note above gives: .container's max-width is calc(--container-max + 2 × gutter), so at the width where the cap starts binding the centring margin is exactly one gutter and the two arms of the max() meet. Below it the first arm wins and nothing moves; above it the second does.

The percentage resolves against the user's containing block, not the viewport. So this token only means what it says on an element whose containing block spans the page — which is exactly the condition under which reaching for --gutter was ever defensible. On anything already inside a .container, the column is the containing block and no inset is needed at all.

The mockups draw all four on the column, and at the 1440 frame they were measured on, the gutter and the column edge differ by 0.8 px — which is why the wrong token survived. Nothing below 1440 renders differently for this change.

The Über uns header object had the same fault and is fixed a different way, because it is the one case where the column edge is not the answer: it stops two columns short of it, so it sits inside .container and measures from there. When an element can be put in the container, that is always the better fix — the container is the column, and a token that merely computes the same number is a second copy of the decision. --column-inset is for the elements that genuinely cannot.

12 columns

123456 789101112

The splits that recur in the design: 6 + 6 for process cards and statements, 12 for section headers and full-width bands, 4 + 4 + 4 for card rows.

Stacked first. A .col-* is full width until the container it sits in reaches 56rem, at which point it takes its real span. The wide layout is the enhancement, not the default — so a browser without container-query support, or a .grid dropped outside any .container, stacks rather than overflowing. That is the same shape .cf-statement, .cf-process and .cf-value-row already use.
…unless the row says otherwise: .grid--early. 56rem was measured for .cf-statement and .cf-process — an illustration beside its copy, which genuinely wants that much room before splitting is worth it. .col-* inherited the number because it is the same shape, and for a row of independent text blocks it is too high. Add .grid--early to the .grid and its children take their spans at 44rem instead.

The cost of not having it was measurable, not theoretical. On the one page that uses this grid, the 7 + 5 stacked at every width below a viewport of ~1007 — drawing form fields 801 px wide at 900 and 895 at 1006, well past any line length a form wants, then snapping to 522 + 366 at 1024. 238 px of viewport where the split would have been comfortable: at 900 the row is 457 + 320.

The threshold belongs to the row because no geometry can derive it. One number cannot serve twelve spans — at 44rem of container these are the widths on offer, and only the author knows which of them their content survives:
Spanat 44rem containerat the 1440 frame
.col-297193
.col-3158302
.col-4219411
.col-5279519
.col-6340628
.col-7401737
.col-8461845
A .col-6 at 340 is comfortable; a .col-3 at 158 is not. So the modifier is opt-in per row rather than a new default, and it moves every span in the row together. Tiering by span was the other candidate and is wrong: a 7 + 5 whose halves promote at different widths draws the 7 beside a 12 and breaks the row. Pairs have to switch together.

44rem is not a new threshold — it is already in the register as the width where .subdivide folds — so this gives an existing number a second consumer rather than lengthening the register.
…and when the cells are sections, not cells: .grid--sections. --grid-gap is the distance between two cells of one row. It is the wrong distance the moment the row stacks and each half opens with a cf-section-header of its own: the header claims more air above itself than 24 px, so the two halves run together and the second reads as a continuation of the first. .grid--sections puts --section-gap-sm on the row gap — the distance the rest of the system already uses above a section header — and touches nothing else. Side by side there is one row, so it is inert above the split and the column rhythm is never affected.

It is a class rather than an inline row-gap for the reason .flow-8 is: a rule this page describes as a rule of the system, but which only exists as an attribute on one page, is reachable only by copying that page.
The threshold is the container, not the viewport. A size query measures the container's content box, and the gutter is 5.5vw — so inside a .container, 56rem of container is reached at a viewport of about 1007 px, not 896. Measured, not derived. Worth knowing before you reach for a viewport number to describe when something folds.

1007 is the figure for a page .container. There is a second one. A named query matches the nearest ancestor whose container-name includes layout — so which element answers @container layout depends on what is named, and a documentation-only wrapper can take the answer without any threshold changing. Both cases, measured:
Nearest layout container.col-* takes its span at
.container — the page columnviewport ~1007 (content box 896)
a framed .docs-demo, if it carries the nameviewport 1330 (content box 898)
Which one applies is not a fact about this page — it is whatever container-name is set to in docs.css, so check there rather than trusting a sentence here. An earlier version of this note asserted the frame was anonymous and therefore could not answer; that was true when written and is exactly the kind of claim that goes false on somebody else's merge, which is why it now gives both numbers instead of one plus an assumption.
Two 6 + 6s. A statement or a process card is a 6 + 6 split, but it is built as a component with its own two-track grid rather than as two .col-6 children. That is deliberate: those two halves are one object with one contour around them, and they need to fold on the width of that object. .col-* is for independent siblings that happen to sit side by side.
One page uses this grid, and it is not one of the two designed ones. This note used to read "nothing on the site uses this grid yet … documented here as a promise, not as a description of the two pages that exist," and it was true when it was written. There are seven pattern pages now, and patterns/kontakt.html composes its whole body with .grid: a 7 + 5 split, form left and contact list right, carrying .grid--early so it splits at 44rem and .grid--sections so the row gap is --section-gap-sm, because stacked they are two sections rather than two cells. That second modifier used to be an inline row-gap on the page itself, which made this paragraph the only place the rule was written down.

The rest still holds. The two designed pages do express their splits at component level — the two 6 + 6s above, and the 4 + 4 + 4 card rows as auto-fill tracks that pack themselves — so the demo above is still not a picture of those pages. The part that stopped being true is the promise, and a promise that has quietly been kept is exactly the kind of claim nobody re-reads.
Two of the eight span classes have a consumer, and four spans cannot be written at all. Counted across the whole system: .col-7 and .col-5 are used once each, in kontakt.html; .col-2, --3, --4, --6, --8 and --12 have no consumer anywhere. And the set has holes — there is no .col-1, --9, --10 or --11 — so .col-3 and .col-2 have no complement: 3 + 9 and 2 + 10 are unwritable, while every three-part row the set suggests (4 + 4 + 4, 3 + 3 + 3 + 3, 2 + 2 + 8) is fine.

Both facts are recorded rather than acted on, and deliberately, because the two obvious actions contradict each other and neither is this page's to take. The space scale's rule below — if a step loses its last consumer, delete it — would cut the set to .col-5 and .col-7, which is absurd for a 12-column grid whose canonical splits are 6 + 6 and 4 + 4 + 4. Completing it instead adds four classes nobody has asked for, which is the menu the same rule exists to prevent. The real question is whether span belongs in a class at all: this system's own stated rule is that a number is a custom property and only what CSS cannot generate is a class — which is why --rank and --subdivide-count are properties. Span is a number. A .col with --col would have no holes by construction, and it is a change to a public API with one consumer, so it wants a ruling, not a routine.
3-up folds straight to stacked. A row of .col-4 goes from three across to full width in one step; there is no 3 → 2 → 1 ladder, because that would need a second container threshold and the register below is deliberately short. If a three-card row ever needs an intermediate state, add the threshold to the register rather than to the component. .grid--early does not change this: it moves the single step earlier, from 56rem to 44rem, and does not add a rung between three and one.
Never a bare 1fr. 1fr is shorthand for minmax(auto, 1fr), and auto floors at min-content — so one long unbreakable headline in a track sets a floor the whole grid cannot go below, and the page scrolls sideways. Every track in this system is written minmax(0, 1fr), and any grid or flex child that has to be allowed to truncate carries min-width: 0.

Breakpoints

A threshold cannot be a design token. var() is not allowed in the prelude of @media or @container, and @custom-media needs a build step this system does not have. Every threshold is therefore a literal typed into the CSS, and the only defence against them multiplying is to write them all down in one place and add to the list on purpose. This table and the register comment in tokens.css are that place. It covers the stylesheets that ship — the three shared ones and acts.css, the landing page's own sheet; the documentation pages carry a few local thresholds of their own for demos, which ship nowhere and are not part of the system.

The rule. A component asks its container. Only page furniture that is genuinely pinned to the viewport — the nav bar, the hero, the footer, the consent banner — may ask the viewport. A new component that reaches for a media query is almost always the wrong answer.
ThresholdKindWhat it governs
28rem / 448container.cf-annot stops being a callout and becomes a legend under the figure — the system's one named container, cf-annot-fig, because what decides whether a callout fits is the width of the drawing
30rem / 480container.cf-breadcrumb keeps only the last two crumbs
34rem / 544container.cf-error__route drops its label above the description; .cf-pagination hides the page numerals
34.625rem / 554container.cf-process__figure holds its --trace-weight constant below the width where the figure's height cap stops binding — the one fractional threshold in the system
44rem / 704container.subdivide folds to a stack; the blog axis goes with it. This is the number for three columns — a five-column grid takes .subdivide--late and folds at 56rem below. .cf-logo-wall has left this row for 64rem below. It sat here behind a one-line width of 617.0 px, and that width was seven mono words — the wall was still carrying stand-in text. This row said of itself that it was the register’s one threshold whose trip point was a string rather than a box, that no script could hold it, and that it would move the day the wordmark files landed. It did: the marks are 988.52 px wide now, which no longer fits above this fold. The row moved rather than the number, because 44rem is already three columns of .subdivide and .grid--early’s opt-down and neither has anything to do with a logo
48rem / 768container.cf-value-row splits into icon + copy
56rem / 896containerthe two-column threshold — .col-*, .cf-statement, .cf-process. Also .subdivide--late, the fold a five-column subdivision needs, and the blog axis with it: at 44rem the applied form's tail cell is 116 px of text and two of the landing page's twelve tail headlines rendered as a fragment of one word plus an ellipsis. The floor solves to a 775 px container; 48rem misses it by 5 px, this clears it by 121. A second consumer for an existing number, not a second number
60rem / 960container.cf-article splits into rail + body; the rail goes sticky
72rem / 1152container.cf-logo-wall justifies its marks to the measure at or above this width and centres them below it, because below it there is always a remainder row and space-between justifies the remainder too. A new number, which this register does not hand out, so here is the arithmetic that earns it: the seven partner marks at the wall’s 2.5rem are 132.70, 86.14, 174.61, 60.72, 150.89, 99.41 and 94.27 px, six --space-12 gaps are 288, so the wall holds one line at 1086.73 px — 67.92rem. A size query measures the content box and .container’s content box is the wall’s width, so 1086.73 is what the query has to clear: 60rem is 960 and would strand a mark on a justified last line. 68rem would also clear it, by 1.27 px, and that is why this is 72 — the trip point is a sum of declared boxes now rather than a rendered string, so every browser gives the same figure, but those widths are fractional and sub-pixel rounding differs between engines and zoom levels. 72rem clears by 65.27 px and costs the 1086.73–1152 band, a viewport of about 1223–1294, centred where it could have been justified at up to 32.6 px an end. Binary-searched on the shipped page: one line from a viewport of 1222, and the query engages at 1295 where the container’s content box reaches 1152.56. It was 64rem for one release: Azure had shipped as the glyph alone — a bare A, 15.19 px wide, beside six marks that each say their own name — and Spark at the normalisation rule’s own answer, which is the one place that rule under-reads. Naming the brand and matching its neighbours cost 84.22 and 14.00 px, and the wall stopped fitting. Moved to a container threshold from 44rem originally, where the trip point was a rendered string; it is a sum of declared boxes now, because the marks are SVGs normalised to a shared optical height before they were committed (assets/img/partner/README.md), so it no longer moves when a font lands or a type scale is edited — only when the set of marks changes, which is the event that has now moved it twice
48.75rem / 780viewportnav collapses to a toggle; accordion loses its indent; footer CTA folds
56.25rem / 900viewportpage-header figure is dropped; consent banner goes single column; hero action drops into the flow — moved here from 48.75rem, which measures the nav's links and not the hero's bottom row. The action floats at the column inset over a width: fit-content kicker on the same band, and between the two numbers the kicker's last words ran under the glass: 70 px of them at a 16 px default, 109 px at a 24 px one, over a band that widens with the reader's type. A second consumer for an existing number, not a second number. Now a third: the hero's reading scrim goes to --scrim-depth 0.5 here too, because the same reflow shortens the media box from 624 px to 567 and puts the square artwork's dark bottom band under the 11 px kicker — 4.15:1 at 900 × 800 against 13.25:1 at 1024 × 768, with nothing gradual in between
51.25rem / 820viewportthe Über uns values section takes its pinned, scroll-scrubbed form; below it the values stack and read straight through. Paired with the 45rem height row below — width alone let the hijack run on a landscape phone
64rem / 1024viewportthe pinned track — .cf-pin, the stage Expertise's fields and the landing page's process both scrub their builds on. Adopted from the landing page, which derived it: the stage is only habitable once the step has its two columns, and that fold is the container 56rem above
95.999rem / 1536viewportthe act rail is paint at rest only above this — the rail's row needs 108 px and the page margin holds it only once --column-inset reaches its 128 px arm at 96rem; below, the rail hides at rest and :focus-within still lifts it. Written as 95.999 so the off tier ends where the fit begins instead of both matching at exactly 96rem. Lives in acts.css, the landing page's own sheet
45rem / 720 heightviewport heighta pinned track's other gate — a stage that must also clear the nav band cannot hold a card worth reading under 45rem of viewport; measured on the landing page, adopted by Expertise unchanged. A third consumer now: the Über uns values section (51.25rem, above) shared the same construction and never asked this question, so a phone turned sideways — 932 × 430, 915 × 412, both real device sizes, both Chromium and WebKit — cleared the width gate and ran the scroll hijack this height gate exists to stop
78rem / 1248viewportwith 60rem height below, the pair that decides whether act 5's map key stands beside the drawing or under it — one decision, two entries, because a media query cannot state a ratio. Inside the pin gate the map row is what the copy and the key leave of one viewport, and preserveAspectRatio="meet" fits a 1.7943 drawing into it: on a wide, short stage that box is far wider than tall, so the drawing scales to the short axis and leaves a letterbox down both sides — 203 px each at 1440 × 900 — which is dead ground the key can occupy for free. This term is where the stage can afford the 14rem column at all; 1024 × 768 clears the height term and fails this one
60rem / 960 heightviewport heightthe other half of 78rem above, and the term that names the letterbox: above this much viewport height the map row is deep enough that the drawing is limited by the stage's width rather than its own, so the dead ground the key would stand in does not exist. Measured at 1920 × 1080: 1225 px of drawing stacked against 1024 px beside. Written as a max and not a min — the one height gate here that turns a layout off as the screen grows, because what it guards is a shape and not a fit
35rem / 560 heightviewport heightthe consent banner takes its short-screen form — the ghost rejoins the accept/reject row, the block padding drops a rung, and the remainder caps against the nav band and one control rather than a share of the screen
Every threshold is in rem, and it used to be true of only half of them. The five container rows have always been rem; the three viewport rows were px. That is not a tidiness point. A media query's rem resolves against the browser's default font size — not against anything an author writes on :root — so a rem threshold is the only kind that moves when the reader has asked for larger type. Every query in this table is a "does this still fit" decision, and what fits depends on how big the type is.

Measured on the two designed pages, sweeping every viewport width from 320 to 2000 px and counting the widths at which the document scrolled sideways:
Browser default fontbeforeafter
16 px00
20 px00
24 px120

The clearest of the twelve was the nav. It collapses to a toggle below 780 px, but at a 24 px default its expanded links need 823 px — so between 780 and 823 the bar did not fold and did not fit, and every page in the system scrolled sideways by up to 43 px. 48.75rem is 780 px at a 16 px default, to the pixel, and 1170 px at a 24 px one, which is where the fold is actually needed.

Nothing moves at the reference. The conversion is arithmetic — 780 / 16 = 48.75, 900 / 16 = 56.25, 560 / 16 = 35 — so at a 16 px default every one of these queries fires at exactly the width it always did. The remaining widths in the "before" column were a different fault, and are dealt with under Reflow and the long German word below.

Do not revert one of these to px. It would render identically at the default and silently opt that threshold out of the reader's preference, which is the least visible way this table has yet found to be wrong.
Two of these were missing from the register. 60rem and the 560 px height threshold were both live in components.css while this table and the register comment in tokens.css claimed three container thresholds and two viewport ones. That is exactly the drift the register exists to prevent, so the fix is not only to list them but to say so: a threshold and its register entry belong in the same commit.
And then two more were, including one this page argued could not exist. The note above shipped with the table it corrects, and the corrected table was still short by two: 34rem was live in components.css with two consumers — .cf-error__route and .cf-pagination — and .cf-error--page split its columns at @media (min-width: 62rem), a figure that appeared exactly once in the whole repository and in neither copy of the register.

The 62rem is the worse of the two, because it is not merely unlisted — it breaks the rule stated directly above this table. An error block is not the nav, the hero, the footer or the consent banner; it is a component, and components ask their container. The code's own comment even claimed it: "a grid that folds on its own", written over a media query. A rule with a register and a stated exception list still needs someone to grep the preludes, because prose above a table does not enforce anything below it.

It was not converted, it was removed. A size query measures the content box, so at the viewport 992 where 62rem fired, a .container is 992 − 2 × 5.5vw = 882.9 px = 55.18rem — the two-column threshold to within 0.82rem. The two were one threshold in different units, and the second copy was the one nobody had written down. It now reads @container (min-width: 56rem), unnamed, alongside .cf-statement and .cf-process. The split moves from a viewport of 992 to about 1007: later, so the two-column state is 13 px roomier when it arrives and never tighter, and the block now folds correctly inside a column instead of only at full bleed.

The lesson is one level up from the last one. A near-duplicate threshold is harder to see than a missing one, because it is not wrong at any width — it simply asserts a second opinion about the same moment, in units that make the collision invisible. Before adding a threshold, convert it into the other kind and check what it lands next to.
And then three more were, and that is the end of keeping this by hand. 28rem, 30rem and 34.625rem were all live container queries in components.css — the annotation layer folding to a legend, the breadcrumb truncating to its last two crumbs, and the process figure holding its trace weight — while this table and the register comment listed five container thresholds. One of the three had been added the same day the sweep that found them ran.

Count the pattern rather than the rows. Every previous correction on this page ended with an instruction to the next person: grep the preludes, and do not trust the previous fix to have been exhaustive. Four separate times someone followed it, and four separate times the register was short again within a few commits. At that point the instruction is not the fix — nothing ran is the fix, and it is the same sentence the space scale, the light family, the isometric assembly, the glass budget and the grid tracks each turned out to be one script short of.

scripts/check-breakpoints.py is the sixth. It reads every @media and @container prelude in the three shipping stylesheets and holds the three copies of this rule to each other in both directions: a query with no register entry is a finding, a register entry no query reaches is a finding, and this table disagreeing with the comment in tokens.css is a finding. It keeps the rem rule stated above, it re-derives every px gloss from its own rem figure rather than comparing the number against itself, and it understands the range syntax (width >= 40rem) so that adopting it later cannot quietly opt a threshold out of the check. It strips comments before reading, which is load-bearing rather than tidy: the register lives in a comment that quotes both live and removed queries in its own prose.

One consequence worth stating, because it is the first thing anyone will want to undo. The ordinal is gone from the 60rem entry. It read FOURTH, then FIFTH, and was stale both times the list was — an ordinal in a hand-kept register is a second thing to remember that says nothing the list does not. The count is whatever the script prints.
34.625rem is the one entry that is a question rather than an answer. It is the only fractional threshold in the system and it sits 10 px from 34rem — precisely the near-duplicate the note above warns about, and the collision is invisible for precisely the reason given there. It is registered rather than reconciled, deliberately: 554 px is not a chosen width but a derived one, the card width at which the process figure's height cap stops being the tighter constraint, so it cannot be moved onto 34rem without moving where the trace weight steps. The two also never measure the same container, so nothing collides today. Whether a geometric crossover should be spelled as a threshold at all belongs to whoever owns the illustration, not to this register — but it is on the list now, so the question is at least askable.
What sits outside the register, in full. The register covers the three stylesheets that ship. Five width queries live outside it — one in docs.css, three in per-page <style> blocks, one in a prototype — and three of the five repeat a figure from the table above. None is a bug: each query is correct and out of scope by design. But a scope boundary that lists some of its contents is the same failure as a register that lists some of its thresholds, so here is all of it. (The two prefers-reduced-motion queries in prototypes/ are not thresholds and are not listed.)
WhereQueryCollides with
docs.css@media (max-width: 900px) — collapses the sidebarthe 56.25rem viewport row at a 16 px default, same type — but it has a consequence, see below. Still px, deliberately: converting it would imply the register governs the documentation chrome
foundations/materials.html@media (max-width:780px)the 48.75rem viewport row at a 16 px default, same type. Now a genuine divergence rather than a duplicate: this one does not follow the reader's font size
foundations/iconography.html@media (max-width: 48rem) — a viewport querythe container 48rem row — different type
foundations/layout.html@media (max-width:640px) — the demo grid's gapno other query; but see the crossovers below — 640 is not unused as a number
prototypes/werte-scroll.html@media (max-width:820px)the 51.25rem viewport row at a 16 px default — and that row is new, see the note directly below
Why prototypes/ is out, and why the 820 still matters. The README is explicit that the prototypes are not part of the system and carry their own styling, unreconciled with the tokens — so excluding them is right, and reconciling that 820 would be wrong. It is listed because an unstated exclusion is the same failure as an unlisted threshold, and because of where the number falls: 820 sits between 780 and 900, the pair this page flags as an open question. Whoever eventually rules on the two viewport tiers should know a third inherited number is already in play on a page the sidebar links to.

Worth recording how this row was missed rather than just adding it. The sweep behind the table was run on this branch, which was cut before the prototypes landed, so the enumeration was complete against the tree it was measured on and short against the tree it will merge into. That is the same failure as the stale count this page opens with, one level up: enumerate against the merge target, not against your own branch.
No line numbers, on purpose. Each row cites the query text because that is greppable, unique within its file, and cannot rot. Line numbers can: this table first shipped citing docs.css:343 and materials.html:16, and merging the other work in flight at the time moved them to 453 and 32 while nothing rendered any differently. A table whose whole argument is that drift is the enemy should not carry citations that drift silently — and silence is the problem, because unlike a wrong threshold, a wrong line number breaks nothing and so nobody finds out.
The same-type rows are the dangerous ones, not the striking one. The 48rem pair looks like the worst of these because the number matches exactly, but it is the safest: container against viewport is a visible type mismatch, so anyone comparing them sees two different kinds of query. 780 against 780 is viewport against viewport, which nothing catches — someone retuning the viewport tier would sweep a documentation page's sample grid along with it and have no reason to notice. Same for docs.css's 900.

The 640 earns its row for a different reason: it sits in this very file, the page that hosts the register. It is not a number found nowhere else — see below.

In every case: unrelated queries that happen to share a figure. Do not read one as evidence of the other, and do not "reconcile" them.
The register is complete about queries, not about behaviour. A min() or clamp() against a viewport unit also changes layout at a specific width — its arms swap — and none of those appear above, because none of them is a query. That distinction is worth stating rather than leaving implicit: a register whose argument is completeness has to say which kind it means. The three in shipping CSS:
DeclarationArms swap at
.cf-consent__dialogwidth: min(38rem, calc(100vw - 2 * var(--space-4)))640 px wide (608 + 32)
.cf-consent__dialogmax-height: min(80vh, 46rem)920 px tall (736 ÷ 0.80)
min-height: min(92vh, 56rem)974 px tall (896 ÷ 0.92)
Two consequences worth carrying. The 640 row above says "no other query" rather than "nowhere else in the system", because the consent dialog's width crosses over at exactly 640 — the same figure, reached by different machinery. And the height rows are true of queries and false of behaviour: 560px and 720px are the only height queries, but layout also changes at 920 and 974 px tall.

These are deliberately out of the register. A crossover is a continuous function swapping arms, not a rule switching on, and folding them in would make the list longer without making it more useful. The token-level clamps are documented already — the gutter's floor and ceiling under Container, --section-gap's under Rhythm.
Out of scope is not the same as no consequence — and docs.css's 900 has one. Calling it "unrelated, do not reconcile" is right about the query and stops one step short. Below 900 the sidebar goes static and a demo gets the whole viewport; at 901 the 272 px sidebar returns and the demo loses it. Measured on components/blog-grid.html, with .subdivide's content box against its own 44rem (704 px) fold:
Viewport.subdivide content boxState
900851 pxfive columns
901532 pxstacked
1073704 pxstacked — 44rem exactly, and the query is inclusive
1074705 pxfive columns again
So there is a 173 px band, 901 to 1073, in which widening the browser collapses the subdivision grid and widening further restores it. Both edges binary-searched. The consequence worth writing down: no framed demo can show a 44rem component between 901 and 1073. It is documentation-only — docs.css does not ship, and no pattern page behaves this way — but it is the one place where a query this register excludes changes what the register's own subject looks like.

It also explains the 1073 edge rather than leaving it odd: the fold releases at a content box of 705, not 704, because a size query measures the content box and max-width: 44rem includes 704 exactly. Same rule as the ~1007 figure above.
60rem is the one threshold nothing derives. The other four container numbers each answer to something — 34rem is the width the error routes' two-column form needs, 44rem is where the finest subdivision ranks stop being readable, 48rem is the value row's icon cell, 56rem is the system's declared two-column threshold. 60rem is a judgement call, and measuring it does not rescue it: the article rail is a fixed 13rem with a 64 px gap, so at 56rem of container the prose column would be 624 px against a 66 ch measure of 587 px, and at 60rem it is 688 px. Both clear the measure. 60rem buys the prose 1.17 × its measure instead of 1.06 ×, which is a defensible thing to want next to a sticky rail — it is simply not a derived number, and the register should not imply that it is. Measured in Chromium: the split fires at a viewport of 1079 px.

One caveat, and it could invert that conclusion. The 587 px is the only font-dependent number in the argument, and it was measured against the fallback: assets/fonts/ is still empty, so Geist never loads (document.fonts.check("16px Geist") is false) and ch resolves against Arial, whose digit advance is 0.556 em — narrow for a grotesk. The tipping point is 0.591 em: any face wider than that puts 66 ch past the 624 px column, and then 56rem does not clear the measure and 60rem stops being a free choice. So this is the one figure on the page that has to be re-measured the day Geist lands. The direction of the risk favours keeping 60rem.

Whether to reconcile it down to 56rem and let the system carry four container thresholds instead of five is a designer's call, for the same reason the 780 / 900 pair is: it changes designed behaviour on patterns/blog-artikel.html. Left as it is, now on the record. Worth noting that the 62rem above was reconciled onto 56rem in the same sweep, and the two cases are not alike: that one was an undocumented near-duplicate of 56rem whose owner's own comment asked for the container, this one is a documented, measured, deliberately different number with a designed behaviour behind it.

One concrete cost, for whoever rules on it. 60rem is the only container threshold that its own documentation page cannot demonstrate at the reference frame. A demo frame on components/article.html gives 846 px of content at a 1280 viewport against the 960 it needs, so .cf-article shows only its stacked fallback there and splits from 1440 up. At 56rem it would be reachable. That is not an argument on its own — a sticky rail wanting 1.17 × its measure is a real want — but it is a cost the threshold currently carries silently.
Two viewport tiers is an open question. 780 and 900 govern different objects and neither was measured off the mockups — they are inherited numbers. They may well be right, but nothing here justifies two. Left as they are rather than reconciled, because collapsing them changes designed behaviour on both pages and that is a designer's call, not a layout one.
A fourth omission, hiding behind a true statement about a different file. Converting the thresholds meant grepping every prelude in the shipping stylesheets, and that turned up a sixth viewport threshold: components.css gates the Über uns values section's pinned, scroll-scrubbed form at min-width: 820px, and the register had never listed it.

What makes this one different from the three omissions above is why it stayed hidden. 820 was not an unfamiliar number — the out-of-scope table directly above has always carried it, as prototypes/werte-scroll.html's. That entry is correct, and it is a different query in a different file that happens to share a figure. So the number looked accounted for while a live query in a shipping stylesheet was not. The register is a list of queries, not a list of numbers, and reading "820 appears on this page" as "820 is registered" is precisely the error that let it sit there. Before concluding a threshold is known, check which query the entry is about.

Reflow and the long German word

Converting the thresholds removed the 792–820 band. It did not remove the other one, at 320–380 px, because that band was never about a breakpoint. The page copy is German, and German builds compounds: Technologie-Kompetenz and Datenfundament are each one word as far as line breaking is concerned. A word that does not fit does not wrap — it hangs out of its box, and the document's scroll width goes with it. At a 24 px browser default, .cf-process__title was 252 px of unbreakable word inside a 196 px panel.

Two things were wrong, and they needed different fixes.

FaultFixWhy that one
Text overflowing a box that was already the right width overflow-wrap: break-word on the text-bearing elements, in base.css Not anywhere: both break a word with no other option, but anywhere also shrinks the element's min-content size, which would let grid tracks across the whole system collapse to one character at widths where nothing was wrong. break-word is inert until a line genuinely cannot be set.
A grid item refusing to shrink below its own min-content, so the long word set the width of the whole column min-width: 0 on .cf-values__list, and an explicit minmax(0, 1fr) track on .cf-values__frame Every explicit track in the system already carries minmax(0, …) for exactly this reason. An implicit single-column grid gets an auto track instead, so this frame was the one grid quietly exempt from the rule the rest of it follows — and the track is only half of it, because a grid item's min-width: auto floors it at min-content whatever its track allows.
The net is not typography. Breaking a word at the edge of its box is the last resort, not the right answer. The right answer for German is hyphenation at the dictionary's break points — hyphens: auto, which these pages already have the lang="de" for. That changes how the type reads, so it is a decision for the typography lane and for a designer. Until it is made, the page does not scroll sideways, which is the part that could not wait.
Where it still fails. At a 28 px browser default (175 %) two widths on ueber-uns still overflow, and at 32 px, ten do. Both are past what WCAG 1.4.4 asks for and both are the same long-compound problem meeting a 320 px viewport, where there is genuinely not room for the word at that size. Hyphenation would take most of it. Recorded rather than fixed, so that the next person to sweep this knows the floor was measured and not assumed.

Swept again, and the largest single cause was not the type. Across all 43 documentation and pattern pages at four combinations of viewport and root font, the count of pages whose body scrolls sideways is:

ViewportRoot fontBefore .tilesAfter
32016 px5 of 431 of 43
32020 px14 of 439 of 43
37520 px7 of 433 of 43
76824 px0 of 430 of 43

Everything from 375 to 1920 px at a 16 px root is clean and was clean before. What the table measures is the corner where a narrow viewport meets an enlarged root font, and four fifths of what it found there was one missing term in one repeated recipe — the tiles guard. The nine that remained were four separate causes, none of them that one, each recorded here for the lane whose call it is:

WhereCauseNote
patterns/kontakt.html .grid's own twelve tracks --grid-gap is --space-6, which is a rem, so at a 20 px root it is 30 px. Eleven internal gaps are then 330 px inside a 270 px container — the gaps alone overflow, before any content. Every .col-* is already stacked to span 12 at that width, so all twelve tracks are pure overhead.

Fixed, the way this row proposed, and verified before it shipped. The stacked state now declares one minmax(0, 1fr) track, and the twelve come only with the spans, inside the same 56rem and 44rem queries. By the time the run that could verify it arrived, the one page had become five — karriere, karriere-stelle, kontakt-danke and impressum compose the same 7 + 5 — and each measured 35 px of document overflow at 320 / 20 px, each 0 after. The other half of the verification is that the change is invisible where nothing was wrong: full-page screenshots of all five pages at 320, 375, 768, 900, 1280 and 1920 at a 16 px root, byte-identical before and after, because twelve minmax(0, 1fr) tracks under a full-span item and one track are the same picture until the gaps outgrow the box.
patterns/blog-artikel.html .cf-plot's rem floor, not .cf-prose's tracks 65 px at 320, 10 px at 375 — the figures were right and the cause was not. This row blamed the prose's two tracks; the element walk puts every overflowing box inside the plot, whose unit is clamp(1rem, 3.4vw, 2rem) — a rem floor, so the five columns and four gaps that the plot's own comment prices at 288 px "fitting a 375 px viewport" are priced at a 16 px root and come to 360 px at a 20 px one. The plot is the isometric drawing system's object, so the fix is its lane's; the record here is corrected so the right lane finds it.
components/team.html .cf-team-strip__list's four tracks A fixed count that does not fold. The strip is a component decision, not a layout one.
foundations/colors.html .docs-ramp__steps, repeat(10, 1fr) The only page failing at a plain 16 px root. Documentation-only, and a ten-step colour ramp genuinely wants ten cells in a row — it needs a scroll container, the way .docs-table-scroll already handles wide tables.

Space

A 4 px base unit. The step number is the multiple, so --space-6 is always 6 × 4 px and the name survives any future insertion. Every multiple of 4 up to 24, then the doubling run. These values only, no steps in between.

1
4
2
8
3
12
4
16
5
20
6
24
8
32
12
48
16
64
20
80
24
96
30
120
40
160

The most common values in the design: --space-6 (24) as the default gap between elements, --space-12 (48) as panel padding, and --space-20 (80) as the page gutter.

Every step is accounted for. A scale is only honest if you can say who uses each rung, so here is the count of declarations that reference each step in the shipping CSS (tokens + base + components), comments excluded. Stamped 45774a4d — see below for what that is and how to check it in one command:
SteppxDeclarationsWhere it earns its place
--space-1411the gap under a name, inside a tight cluster
--space-2847icon-to-label, chip padding
--space-31259button padding-block — the most used step in the system
--space-41651the default gap: what .stack and .cluster do unaided
--space-5205the contact list's and the error routes' row padding, a prose note's block padding — the thinnest-used rung
--space-62440--grid-gap, panel padding
--space-83233the gap between an object and its copy
--space-124819panel padding at the wide breakpoint
--space-166410the --section-gap floor
--space-20806--section-header-gap, the -sm ceiling, the accordion indent
--space-24965page-header and footer padding-block; the field's rhombus width
--space-301200
--space-401602the --section-gap ceiling
Re-measure it rather than trusting it. The previous version of this table was written by hand and six of its thirteen rows had drifted as components changed — --space-3 was listed at 13 against a real 23, --space-6 at 19 against 26. A stale count is worse than none, because it is the one number here anybody would act on. So the table now carries the commit it was measured at, and the measurement is a command rather than a reading. It needs nothing the system does not already assume (python3, the same thing that serves these pages):
cd design-system/assets/css && python3 -c "
import re, hashlib
s=re.sub(r'/\*.*?\*/','',''.join(open(f).read() for f in ('tokens.css','base.css','components.css')),flags=re.S)
counts=[sum('var(--space-%d)'%n in d for d in s.split(';')) for n in (1,2,3,4,5,6,8,12,16,20,24,30,40)]
print('stamp', hashlib.sha256(repr(counts).encode()).hexdigest()[:8])
print(counts)
"
Comments are stripped first, which matters more than it sounds: --space-30 is named eight times across tokens.css and this page and used in exactly zero declarations, so a naive grep would report it as a healthy rung.

The other thing a sceptical reader checks first: the match includes the closing paren, so var(--space-1) cannot match var(--space-12). The narrow steps are not silently inflated by the wide ones.
And it drifted anyway, which is the point. The stamp above did its job — it detected the drift — but detecting is not preventing, and nobody ran it. Seven of the thirteen rows had gone stale within a few commits of being measured: --space-8 was listed at 14 against a real 20, --space-2 at 16 against 21. That is the same failure the stamp was invented to fix, one level up: a self-check that has to be remembered is documentation, not enforcement.

So the command is now a script that CI runs on every push and pull request — scripts/check-spacing-scale.py, stdlib only, no build step, nothing added to what the system already assumes. It fails the build when this table disagrees with the CSS, and --fix rewrites the table and the stamp rather than leaving a human to transcribe thirteen numbers. The table is now generated; the fourth column is not. Editing a count by hand is a mistake — re-run the script.

And the ungenerated column drifted, exactly as the generated one had. --space-4's cell called it "the most used step in the system" while the column beside it read 37 against --space-3's 40 — a claim contradicted by the generated number on the same row. It is the failure this whole apparatus was built to stop, surviving in the one column the apparatus does not reach. A generator does not make a table true; it makes the part it writes true, and the rest is still prose somebody has to mean.
The scale is enforced from both ends. Counting who uses each rung answers whether the scale is earned. It says nothing about whether spacing goes through the scale at all — a literal 2rem in a padding is invisible to a count of var(--space-*), and it is the more common way a scale rots. The same script therefore also fails on any margin, padding or gap in the three shipping stylesheets whose value is a raw px or rem length.

It found two on the commit that introduced it, and they are worth separating because only one was a bug. .visually-hidden's margin: -1px is the clip-rect recipe's own artifact — bound to the 1 px above it, not to the scale — and is allow-listed with that reason. .cf-consent__meta's padding-left: 2rem was the real one: a hand-added sum of the checkbox (20) and its gap (12), correct only until either half moved, and it would have gone silently out of alignment with nothing visibly broken. It is now calc(var(--check-box) + var(--space-3)).

em, ch and % are not flagged. They are relative to something the space scale does not govern — the element's own type, its measure, its parent — so a length in those units is a different kind of decision, not an unticketed one.
The stamp is a digest of the counts themselves. It is the first eight hex of a SHA-256 over the thirteen numbers, printed by the same command that prints them — so if it still reads 45774a4d, the table is current and there is nothing to re-derive.

It took two wrong answers to get here, and both are worth keeping because both are the obvious choice. A commit hash moves on a comment-only edit: the change that first wrote this table also rewrote comments in tokens.css, so a git log stamp would have declared the table stale on the very commit that measured it. A digest of the stylesheet text fixes that and still over-triggers, because it moves when any declaration changes — and almost no declaration in this system touches a spacing token. Measured across four in-flight branches at once, every one of them moved the text digest and not one moved a single count. The first to land would have advertised a stale stamp for a table that was still correct, which is the failure the stamp exists to prevent, one level up.

Digesting the output rather than the input is the version that holds: it changes if and only if a number changes. The general lesson is worth more than the token — stamp the claim, not the material the claim was made from.
--space-30 is the one step with no consumer — and the previous version of this note was wrong about it, claiming it and --space-40 were both values --section-gap merely resolves to. --space-40 is written by hand, as the clamp's ceiling. --space-30 is written nowhere. It is kept as the single deliberate exception to the rule below, because 120 is a real rung of a 4 px scale and it is the value the rhythm lands on at the 1440 reference frame — the landmark the whole vertical system is measured against. If a designer would rather see the rule enforced than the landmark kept, delete it; that is a reasonable ruling and this note exists so it can be made deliberately.
If a step loses its last consumer, delete it rather than leaving it as a suggestion. A scale that lists options nobody took is a menu, not a system. The count above is the check — run it before adding a rung, not after.

Vertical rhythm

Two fluid tokens carry all vertical air between sections. Both are one fraction of the frame rather than a hand-picked slope, and both are clamped to a scale step at each end:

TokenFluid partFloorCeiling
--section-gap100vw / 12 — one twelfth of the frame64 below 768160 from 1920
--section-gap-sm100vw / 1832 below 57680 from 1440

The point of choosing the fractions this way is where the clamp stops being fluid. A clamp() whose ends are reached at 800 px and 2000 px is a scale whose declared endpoints nobody ever sees; these hit their floor and ceiling on real device widths, and land on scale steps at the frame the design was drawn on:

ViewportGutterContent--section-gap-sm
37520.633464 (floor)32 (floor)
57631.75136432 (crossover)
76842.268464 (crossover)42.7
102456.391185.356.9
128070.41139106.771.1
144079.21280120 = --space-3080 = --space-20
1920801280160 = --space-4080
These figures are the page, not the demo frames. Every row above is monotonic and every one is right — but the 1024 row sits inside the 901–1073 band described under Breakpoints, where a documentation demo is narrower than it is at 900. Nothing in this table is wrong; it is simply adjacent to the one place the monotonic reading does not carry over. Read these as viewport-to-page relationships, which is what they are.
The 2 : 3 ratio holds in the middle, not everywhere. The two tokens are 1/18 and 1/12 of the frame, so where both are fluid they sit at exactly 1.50. Measured across the range that is 768 to 1440 and nowhere else: 2.00 below 576 where both rest on their floors, sliding 2.00 → 1.50 across 576–768 as -sm leaves its floor first — it passes 1.80 at 640 and 1.65 at 700, and an earlier version of this note named the 640 figure as the start of the slide — flat at 1.50 through the middle, then opening back out to 2.00 by 1920 as -sm caps and --section-gap climbs on alone. This is the failure mode clamp() is known for — two ramps that agree at the ends and drift in between — and it is checked here rather than assumed. The drift is benign: the ratio only widens, so the tight rhythm never overtakes the loose one.
Where the rationale stops being literally true. --section-gap used to be justified here as "one column of the 12-column grid". It is not one, at any width. Measured at the reference frame: content is 1280, eleven gaps of 24 take 264, so a column is 84.67 and a column pitch — column plus gap — is 108.67. 100vw ÷ 12 is 120. The unit is one twelfth of the frame, which still carries both 80 px gutters, and that is a coarser thing than a column. It is a sound unit — the frame is the designer's own measured object and 12 is the grid's own column count — and it is why the number lands exactly on --space-30 at 1440. It is simply not the grid.

The correction sharpens the question below rather than dissolving it. What changes at about 1438 px is that the container caps, so the frame stops governing the page: content freezes at 1280 while 100vw ÷ 12 keeps climbing. Section rhythm therefore keeps opening against a column that no longer grows — measured as a share of content width, the air per section goes 9.4 % flat from 768 to 1440, then climbs to 12.5 % at 1920. At the other end it reaches 19.2 % at 375 and 22.9 % at 320, because the 64 px floor is held while the column keeps narrowing.

Neither end is broken and both look deliberate, but neither was measured off a mockup — the designer's frame is 1440. The alternative is to cap the fluid part at the frame (min(100vw, 89.9rem) ÷ 12), which would freeze the rhythm at --space-30 exactly where the column freezes, and give that step the consumer it currently lacks. That is a visible change to every viewport above 1500 — 240 px between sections instead of up to 320 — so it is left for a designer to rule on rather than taken here.
Four tokens stop growing at four different widths, and that is what the ratio drift actually is. The note above is the visible corner of a wider fact:
TokenStops growing atBecause
--container-max1438.2content reaches 1280
--section-gap-sm1440.0100vw ÷ 18 = 80
--gutter1454.55.5vw = 80
--section-gap1920.0100vw ÷ 12 = 160
Three land within 16 px of each other and the fourth arrives 466 px later. So the ratio opening back out to 2.00 by 1920 is not the rhythm doing something interesting — it is --section-gap climbing alone after every horizontal measure has frozen. From 1454 to 1920 the page is a fixed 1280 column with 80 px gutters whose vertical rhythm keeps loosening by a further 39 px.

Current fluid-scale practice is to coordinate the upper bound across the system so gutters, padding and type top out together and the composition grows in proportion. This system has them four places apart, and the widest governs vertical space. Whether that is wrong is a designer's call — clamp(var(--space-16), calc(100vw / 12), var(--space-30)) would lock section rhythm to the frame at 1440 — and it is the same call as the note above, so it is recorded here rather than taken. Named because a measured fact is worth more to whoever rules on it than an inference.
The fluid middle does not answer to the user's font size, and the ends do. Every fluid token in this system is a clamp() whose floor and ceiling are rem and whose preferred value is pure viewport. That makes the two ends scale with the root font size and the middle ignore it. Measured on the landing page at a 1440 viewport, with the browser's base font at 16, 20 and 24 px:
Tokenroot 16 pxroot 20 pxroot 24 px
--space-832 px40 px48 px
--text-display-164 px80 px96 px
--gutter79.2 px79.2 px79.2 px
--section-gap120 px120 px120 px
--section-gap-sm80 px80 px80 px
At 24 px the display line is 96 px tall against 120 px of section rhythm — a ratio of 1.25 where the design has 1.88 — so a reader who has enlarged their type gets the type and loses the air that separated it. The type scale does not have this problem: it is written 1.4rem + 4.9vw, and the rem term is what carries the root size through the middle of the range.

The rhythm cannot be given the same term without giving up a designed value. Refitting A·rem + B·vw needs two points, and the two this token is built on — 64 at 768 and 120 at 1440 — define a line through the origin: the slope is 56 ÷ 672 = 1 ⁄ 12 exactly, and the intercept is 0. 100vw ÷ 12 is the correct fit, and the zero intercept is precisely why it is blind. Any non-zero rem term moves one of the two endpoints: holding 120 at 1440 and 64 at 375 instead gives 2.77rem + 5.26vw, which buys back only 18 % of the root-size response and costs 33 % more section rhythm at 768 — a visible change to the designed tablet rhythm, paid for a partial fix. So this is recorded, not traded away. The horizontal case is different and is arguably already right: growing --gutter with the root size would take content width away from exactly the reader who most needs it.
The token is padding, not the gap. .section applies --section-gap as padding-block, so it sits on both edges and the air between two adjacent sections is twice the number above — 128 px on mobile, 240 px at the reference frame. Reach for the token when you want one section's breathing room; double it in your head when you are reasoning about the page rhythm.

The three modifiers

Two tokens carry the air, but three classes decide which one a given edge gets. All three were live in the shipping CSS and used by every pattern page while this chapter documented only the tokens — which is how one page came to stand its footer off at twice the distance of every other page without anything catching it.

ClassEdgesUse it when
.section --section-gap both Always. This is the default and most sections need nothing else.
.section--tight --section-gap-sm both The first section under a .cf-page-header, which has already spent air of its own. All four pages with a page header use it.
.section--flush --section-gap top, 0 bottom The last section on a page, when the footer carries .cf-footer--detached. The two together make one gap, not two.
--flush and --detached are one decision written in two places, and they must agree. The footer modifier adds margin-top: --section-gap-sm to replace the bottom padding the flush section gave up. Put it on a page whose last section is a normal .section and both distances apply: patterns/kontakt.html measured 240 px above its footer where every other page measured 120. The rule is a biconditional — last section flush if and only if footer detached — so check the other one whenever you change either.
--flush drops the trailing edge only. It used to zero both, and that put 120 px of air above the Über uns team section where the site's rhythm is 240, and 80 px above the Blog article's Weiterlesen where its siblings have 200 — neither of them anything a page asked for. It also read against the system's own idiom: .cf-section-header--flush gives up its bottom border and gap, .cf-pagination--flush its top margin and border, and flush means giving up the one edge a neighbour is already drawing. Both pages now measure the same as their siblings without either page changing.
Why these are pure viewport fractions. The fluid type scale deliberately mixes rem into its clamp() so that browser zoom still moves the text — a pure vw size would sit frozen while the rest of the page grows, and fail WCAG 1.4.4. Space is the opposite case: it should stay proportional to the frame, and it reflows rather than clipping when the frame narrows. Checked against the same 2.5× rule the type scale is held to, every display step passes with room to spare — the widest ratio in the system is --text-display-1 at 1.6×.

Subdivision grid

The recursive square-subdivision system from the manual: halve a surface, halve the half, and so on. Applied as an editorial layout it produces hierarchy automatically — the newest article gets the largest area, older ones shrink to rows. That is exactly how the blog grid on the landing page is built.

1/2
1/41/4
1/81/81/81/8
1/81/81/81/81/81/81/81/8

Labels are column widths. Each column is half the width of the one to its left and carries twice as many rows, so every cell keeps roughly the proportion of the whole. The series has to terminate somewhere: the last two columns are the same width, which is what makes the fractions sum to exactly one.

The primitive

The manual draws the system twice: once pure, with each column half the width of the one before it, and once applied to a blog index, where the columns are equal and the hierarchy is carried by the row count alone. Both forms are available as .subdivide, so a subdivision layout is a composition rather than a fresh block of bespoke CSS each time.

ClassColumns
.subdivide--41/2 · 1/4 · 1/8 · 1/8 — the pure form, exact halves
.subdivide--3, --5, --6the same series at other depths
.subdivide--evenequal columns — the applied form; count via --subdivide-count
.subdivide alonethe applied form as well — the base class carries it as its default, so no modifier is not an error state
.subdivide__colone column; --rank sets how many rows it carries
.subdivide__rowa full-width band that adopts the parent's tracks — for a second object that has to line up with the columns
<div class="subdivide subdivide--even">
  <div class="subdivide__col" style="--rank:1">…</div>
  <div class="subdivide__col" style="--rank:2">…</div>
  <div class="subdivide__col" style="--rank:4">…</div>
</div>

Depth is a class because grid-template-columns cannot be generated in plain CSS; rank is a plain number, so it is a custom property. Below 44rem of its own width the grid folds: every column spans the full track set and they stack. That threshold is a container query, not a media query, because the same grid is comfortable in 900 px of page and unreadable in 900 px of viewport with a 272 px sidebar in front of it.

The base class used to have no behaviour of its own, and that is a worse failure than it sounds. --subdivide-cols was written only by the five modifiers, so .subdivide with no modifier read a property nothing had defined. An unresolvable var() makes its declaration invalid at computed-value time, so grid-template-columns fell all the way back to none and the field collapsed to a single implicit track — every column drawn at full width, on top of every other. Silent, because a grid with one track is still a grid: nothing errors, nothing overflows, and the page still passes an overflow sweep.

components/pagination.html shipped in that state. It asked for three columns with --subdivide-count:3 and no --even, and drew three cards 1071 px wide inside a 1072 px track. Measured, not inferred.

The fix is that the base class now declares the default rather than depending on a modifier to supply one. Defaulting to the applied form is not a guess about intent: it is the manual's own Teilungsraster (Anwendungsbeispiel) plate, and it is the form all five shipping uses of this primitive take. --subdivide-count therefore works on the base class, which is what the pagination markup already assumed. The five call sites are now written the same way regardless.
One bug, not a pattern — checked rather than assumed. Every var() in base.css and components.css that names a non-token property and carries no fallback was traced to the rules that write it. --subdivide-cols was the only one read by a base rule and written by nothing but modifiers. --rank and --subdivide-count carry fallbacks; --btn-w is read only by the two selectors that set it; --cf-specular is registered with @property and so always resolves. Worth re-running before a sixth modifier is added, because the check is cheap and the failure it catches is invisible.
A subdivision is rarely one object. The application plate draws the ruled grid and, under it, a mono axis whose ticks name the columns. Those cannot be one element — the ruled box's contour stops at the grid — but they have to agree on the same track set forever. .subdivide__row is that agreement: a band spanning 1 / -1 whose own columns are subgrid, so it reads the parent's tracks rather than restating them.

Before this the axis carried its own repeat(5, minmax(0, 1fr)) as a sibling of the grid. That held only because both happened to say five, and patterns/blog-artikel.html already runs the same grid at --subdivide-count:3 — an axis there would have put five ticks under three columns, silently. Measured after the change: ticks 2..n sit within 0.00 px of the column edges they name at 900 / 1024 / 1280 / 1440 / 1920, against roughly 1 px of drift before.

Tick 1 sits 1 px left of column 1's content, and that is correct rather than tolerated: a subgrid subtracts its own border from its first track only, so the grid's 1 px left contour narrows its own first column and every line after it still falls on the parent's. The tick therefore stands on the rule, not 1 px inside the cell, which is how the plate draws it.

Two grid-template-columns declarations, on purpose. The second is the real one; a browser without subgrid drops it and keeps var(--subdivide-cols), which inherits from .subdivide and resolves identically while the grid carries no column-gap — which it does not. subgrid is the one that stays correct if a gap is ever added, because a gap belongs to the track set and the custom property does not carry it. Subgrid reached Baseline Widely Available in March 2026, so the fallback is for old installs rather than for any shipping browser.
Which grid when. The 12-column grid orders content — text, forms, cards of equal rank. The subdivision grid orders quantity — article lists, archives, anything with many items of unequal weight. The two are never nested inside each other.
The plate also claims the system is interactive, and that is a component rather than a layout. "Das rekursive Quadrat-Teilungssystem … kann als Konzept für interaktive UI-Elemente oder Menüstrukturen genutzt werden." Everything on this page is the static half of that: .subdivide is a track set, and the hierarchy in it is authored once because which article is newest is a fact about the archive rather than about the reader. The moving half is Subdivision Field — the same halving series with the head handed to the reader, built on flex-grow rather than on these tracks, because a track list cannot interpolate and a unitless number can. They are two implementations of one system on purpose, and the series they draw at rest is identical.
Rank should double. The manual's application plate runs 1, 3, 4, 8, 16 articles down its five columns — near enough to a doubling that the intent is clear. The blog grid currently runs 1, 2, 3, 6, 6, which flattens the tail: the last two columns are the same rank, so the finest subdivision never appears. That is a content decision rather than a layout one, so the primitive supports any rank and the markup has been left as it is.

Flow primitives

Two primitives put things in a line, and one set of rungs spaces either of them. .stack runs down the block axis and spends its spacing as margin between siblings; .cluster runs along the inline axis, wraps, and spends it as gap. Both read --flow and both default to --space-4, so the rung classes are shared rather than owned.

ClassDoesSpacing
.stackcolumn, spacing between siblingsmargin-top on * + *
.clusterwrapping row, vertically centredgap
.cluster--betweenpushes the row's ends apart, no wrapgap
.flow-2 / -3 / -6 / -8 / -12sets --flow on either primitivethe matching --space-*
.after-registerstands one block off a register that has already closedmargin-top: --space-8

The rung number is the scale's step number, the same convention the space scale uses: .flow-8 is --space-8 is 32 px. There is no .flow-4 because 16 px is what both primitives already do unaided.

The rungs used to belong to .stack alone, and the asymmetry cost the system its only inline spacing override. .stack carried five --flow modifiers while .cluster's gap was hard-coded, so the first author who wanted a wider cluster had no class to reach for and wrote style="gap:var(--space-8)" on it, in Iconography's arrow demo. A rung the family was missing turned into a length in the markup — the exact substitution the space scale and scripts/check-spacing-scale.py exist to prevent, arriving through a door neither of them watches, because the checker reads the three stylesheets and this was in an HTML attribute. Naming the rungs .flow-* rather than .stack--* is what lets one set serve both: a modifier that says stack on a cluster would have to be read as a lie or duplicated under a second name.
The coda is the third time that substitution happened, and the last one left in the pattern pages. A register here is a stack of type blocks standing on hairlines and closing on one — .cf-vacancies, .cf-results, .cf-contact. Twice the site puts a sentence after one inside the same section: the initiative application under the open positions on Karriere, the pointer to Karriere under the contact list on Kontakt. Both wrote that distance as an inline margin-top, and they disagreed — --space-12 on one page and --space-8 on the other, for one relationship. .after-register is that relationship with a name, and the number is not a new decision: .cf-pagination already stands --space-8 off the register it closes, on the stated grounds that the distance belongs to the shape and every page wants the same one. A coda is that shape without the controls, so Kontakt was right and Karriere was 16 px generous.
It is a margin and not a .flow-* rung, and the reason is a hazard worth knowing about the rungs. .stack spends its spacing as margin-top on * + *, so var(--flow) resolves on the child — which means a child carrying a rung of its own silently overrides the rhythm its parent declared. .cluster spends its spacing as gap, which resolves on the parent, so it has no such behaviour. The two primitives are symmetric in what they read and asymmetric in where it is read, and nesting is where that shows: a .stack .flow-6 inside a .stack .flow-8 takes 24 px from its parent, not 32. Nothing in the system nests them today. Karriere's coda is exactly that shape — a sentence over a button, .stack .flow-6 — which is why its distance from the register above is stated as its own margin.
.split is gone. It was .cluster plus justify-content: space-between and minus the wrap, and it shipped in base.css with zero consumers — no pattern page, no component page, no foundation. Twelve rules in components.css do write those same declarations, but each writes them on its own class name, which a markup utility cannot reach without a build step this system does not have. So it was never being bypassed; it was simply never wanted in markup. It returns as .cluster--between, two lines instead of five, inheriting the rungs it previously could not use.

Tiles

.grid divides a fixed twelve. .tiles divides by however many fit: the column count is never declared, the browser derives it from one number — the smallest a tile is allowed to be — and the row reflows without a query. It is the shape behind every card strip, swatch band, plate wall and icon wall in the system.

NameWhat it isDefault
.tilesas many equal columns as fit, empty tracks kept
.tiles--fitempty tracks collapse, the survivors stretch
--tilethe smallest a tile may be16rem
--flowthe gap, shared with .stack and .cluster--space-4

--tile is a length, so it is a property rather than a family of classes — the same call the subdivision grid makes with --subdivide-count. Set it in the consumer's own rule rather than in the markup: several of these minima are measured numbers carrying a paragraph of reasoning behind them, and a measured number belongs next to its measurement. .cf-team-grid's 11.5rem is chosen inside a window only 1.8rem wide, and that argument lives in components.css where it can be read.

The guard is the whole point

The recipe underneath is repeat(auto-fill, minmax(min(var(--tile), 100%), 1fr)), and the term that matters is min(…, 100%). Without it the minimum in minmax() is a floor the track will not go below, so a container narrower than the tile does not fold the row — it overflows, because the track keeps its minimum and the grid keeps the track. With it the floor becomes the tile or the container, whichever is smaller, so the last column folds instead of pushing. Above the fold width the term is inert: nothing that fits today lays out differently.

This was written out by hand twenty times, and every copy could scroll the page sideways. The same three declarations appeared once in components.css, three times in docs.css and sixteen times in page-local <style> blocks, each with its own minimum and one of three gaps — and not one of them carried the guard. Measured across the 43 documentation and pattern pages: at a 320 px viewport five scrolled sideways, and at 320 px with the root font at 20 px — where a reader who has enlarged their type is — fourteen did. Folding the recipe into one class took the first number to one and the second to nine, and the nine that remain have other causes, listed under Reflow. Sixteen of the twenty copies were also unreachable from the system: a rule you can only get at by copying it off another page is not a rule, which is the argument that already brought back .cluster--between and the .flow-* rungs.

auto-fill is the base and auto-fit is the modifier. The two differ only when the items do not fill the row: auto-fit collapses the empty tracks so the survivors stretch, auto-fill keeps them so the survivors stay tile-sized. Three cards in a row with room for five should stay three cards rather than become three banners, so the default holds the size and .tiles--fit is how a caller asks for the stretch. Both forms were already in use — eight of the twenty copies were auto-fill and twelve auto-fit — so this is a distinction the system had been making without ever naming.

min-width: 0 on the items is load-bearing rather than hygiene. 1fr is minmax(auto, 1fr) and that auto floors at min-content, so without it a single long German compound widens its own track past its share and the guard above never gets to hold — the same failure Reflow and the long German word describes, arriving through the track sizing rather than through the text. The reset already sets overflow-wrap: break-word on everything, so the word breaks inside the tile rather than escaping it. This is not a rule about .tiles; it is the intrinsic minimum, and every grid in the system answers to it.

The intrinsic minimum

Every track that carries an fr carries a floor. 1fr is shorthand for minmax(auto, 1fr), and in the minimum slot auto is not zero — it is the item's automatic minimum size, which for anything holding text is its min-content width. So a bare fr track does not distribute free space. It distributes free space or the widest unbreakable word inside it, whichever is larger, and the second case is how a single German compound sets the width of a column, then of the grid, then of the page.

The system had been stating this in prose and keeping it almost everywhere: thirty-two of its thirty-three fr track lists carried a minimum — twenty-eight floored on the track, four guarded by min-width: 0 on their items. .cf-progress was the thirty-third and had neither, at grid-template-columns: 1fr auto. Measured with a 61-character compound in .cf-progress__label:

ViewportDocument width, bare 1frWith minmax(0, 1fr)
320469 — 149 px of sideways scroll320
375469 — 94 px375
480480480
overflow-wrap: break-word does not save you, and it is the reason this is easy to get wrong. The reset sets it on everything, so the word visibly breaks inside its box on every page — which makes the whole failure mode look already handled. It is not: overflow-wrap decides how a line is broken once the track has a width, and intrinsic track sizing runs before that. The property that would enter the calculation is overflow-wrap: anywhere, and the system does not use it, because it breaks words mid-line even when there is room. The floor is the fix; the wrap is what makes the result read well afterwards.

Two correct fixes, and the system uses both

WhereWhatWho
on the track minmax(0, 1fr) — the floor is zero, so the track is pure proportion .grid, .cf-process, .cf-article and twenty-four more, and .cf-accordion__summary, which became a grid to get one
on the item min-width: 0 on the grid items, which sets the automatic minimum size to zero and leaves auto nothing to floor at .subdivide, .tiles, .cf-section-header

.subdivide has to take the second one. Its geometric sets are 4fr 2fr 2fr — exact halves whose entire content is the ratio — and minmax(0, 4fr) minmax(0, 2fr) minmax(0, 2fr) states that ratio twice while saying nothing the first form did not. The guard goes on .subdivide__col instead, once, for every depth.

A deliberate non-zero floor passes as well, because the rule is that a floor was chosen and not that it is zero: .tiles floors at min(var(--tile), 100%), which is the guard, and .cf-team-strip__list floors at 15rem because it is a row that scrolls horizontally on purpose and a card narrower than that is not worth drawing.

This is the fifth thing the system checks rather than documents. scripts/check-grid-tracks.py reads every track-list declaration in the three shipping stylesheets — including the custom properties that feed them, --grid-tracks and --subdivide-cols, since in this system the track set is as likely to be decided in a property as written inline — and asks each fr whether it sits in the max slot of a minmax() or whether its component declares min-width: 0 anywhere. It passes on both, and it fails on neither. It was written against the state of the tree that had .cf-progress unfloored, and it found exactly that one declaration.

It belongs in a script for the same reason the other four do: the failure is invisible until the content is long enough. A grid with a bare fr renders perfectly with short labels, in every screenshot anyone takes, and starts scrolling the page sideways the day a real German noun lands in it — on a page that was signed off months earlier.

Section structure

Every section on the site follows the same order:

  1. Section header — mono label left, counter right, hairline underneath.
  2. Content — in the 12-column grid.
  3. Air--section-gap to the next section, no divider.

Sections get no background colour of their own. The sense of depth comes entirely from the page-wide wash and the contours.