Skip to content

SheetArticle

Measure68ch · bone, not white

Filed underTypography · Tooling · CSS

Published2026-08-15

15 August 2026 · 17 min

Three times I thought I'd put the page on the grid

Finding every line of text that misses a baseline grid is arithmetic. Putting a whole page onto one is a constraint problem, and I got it wrong three times in a row, starting with a tool that couldn't see its own corrections.

I built a thing that walks a rendered page, measures where every line of text actually sits, and reports the ones that miss an 8px baseline grid. Pointed at my own homepage it found 219 of 232. Then it sat there, very pleased with itself, having told me my site was broken in 219 places and offering absolutely nothing else.

Which is where this kind of tool normally stops. Capsize will compute the offset for one element and it has done that well for years. The text-box-trim property will remove the overshoot on one element, if your reader is in a browser that has it. A linter will count your failures. Nothing puts the page on the grid, and after a week of looking I decided that was because it's genuinely harder than it looks rather than because nobody had thought of it.

Both, as it turned out.

Before building on a measurement, check it survives the trip

The whole tool rests on canvas TextMetrics. You ask the browser what the font's ascent and descent are and it tells you, in pixels, for the font it actually resolved rather than the one your stylesheet asked for. Everything else is arithmetic on those two numbers.

I'd never checked whether that measurement gives the same answer in a different browser. If it doesn't, then a grid computed in Chrome is wrong in Firefox and the entire thing is decoration. So before writing a single line of the corrector, I ran the same measurement across Chromium and Firefox and WebKit, six typefaces and seven sizes from 12px to 48px, which is 42 readings taken before I had earned the right to an opinion about any of it.

Half of it travels. The other half doesn't, and I'd rather find that out myself than have somebody find it for me.

  1. fontBoundingBox, the baseline

    Chromium
    same
    Firefox
    same
    WebKit
    same
  2. actualBoundingBox, cap height, whole pixels

    Chromium
    42 of 42
    Firefox
    0 of 42
    WebKit
    42 of 42
MetricChromiumFirefoxWebKit
fontBoundingBox, the baselinesamesamesame
actualBoundingBox, cap height, whole pixels42 of 420 of 4242 of 42
The baseline metric agreed everywhere. The cap-height metric agreed nowhere: Chromium and WebKit report the rasterised glyph hinted onto the pixel grid, Firefox reports the scaled outline.

The gap reaches 1.05px on 12px Times, which is 8.8% of the type size and more than double my own tolerance. And you can't correct for it, because it isn't rounding. Firefox reads 10.234 where Chromium reads 11, and 35.088 where Chromium reads 35, which is neither rounding up nor rounding to nearest. Hinting isn't a function you can invert.

Which settled the design before it started. Seat baselines using fontBoundingBox, which is portable. Never build a shipped correction on cap height. Here's the measurement running in whichever browser you're reading this in, so you can see which of the two behaviours you've got.

Measured in your browser, right now. Change the typeface and watch which column moves.
Cap height of H, by size
SizeCap heightWhole pixel
Measuring

Measuring.

Attempt one, and a tool that couldn't see its own fix

The correction itself isn't complicated. Work out how far the first baseline is from the nearest grid line, add that much padding to the top of the block, and the text moves down onto it. Walk the page, do that to everything, done.

I ran it. 12.8% of the page was on the grid. It had barely moved anything.

Except it had. The corrections were all being applied correctly and the verifier couldn't see them, because getBoundingClientRect returns the border box, and the border box doesn't move when you add padding to it. The box stays exactly where it is and the text slides down inside it. My verifier was measuring the top of a box and adding the font's own offset, so it was reporting a position that padding is structurally incapable of changing.

The tool was blind to its own fix, and it had been quietly wrong about every element with a border since the day I wrote it.

That second part is worse than the first, and every number I'd published about this site came out of that verifier. The 18 of 232 that was on my own research card was wrong. Not wildly, but wrong, and wrong in the direction that made my site look slightly better than it was. It's now 13 of 232 and the card says so.

Attempt two, and the boxes that won't be pushed

With the verifier fixed, seating jumped to 76%. Which is a real result and also not a finished one, so I went and looked at the 57 that were left rather than declaring victory at three quarters.

Forty of them were children of a flex or grid container with align-items set to center, end or baseline. Six were inline elements. Once you see it written down it's obvious, and it's not a bug I could patch, because it's just how layout works.

  • Padding lives inside the box. The parent positions the box.
  • Under align-items: center, padding makes the box taller, and the container re-centres the taller box, and the text ends up moving half as far as you asked, and you lose an afternoon to it convinced the browser is broken.
  • Under flex-end the box grows upward from a fixed bottom edge, so the text doesn't move at all.
  • An inline box ignores vertical padding for layout entirely. It will paint it. It won't be moved by it.

So the tool needed a second lever for boxes whose position isn't theirs to choose, which is a relative offset, moving what gets drawn without touching what gets laid out. Adding it was easy, and the interesting decision was working out when to reach for it.

I could have written rules. If the parent is flex and the alignment is one of these three, use the offset. That would have worked for the cases I'd just finished looking at and quietly failed on the first case I hadn't thought of, which is most of them.

So it does the thing the verifier exists for, turned inward. It applies the padding, measures again, and if the text didn't land where it was sent, it reverts and tries the offset. If neither works the block is reported as missed rather than counted as fixed.

el.style.paddingTop = `${existing + needed}px`

// Check the correction rather than trust it.
if (Math.abs(drift(firstBaseline())) > grid.tolerance) {
  el.style.paddingTop = was.padding
  // Move what is drawn, not what is laid out.
  el.style.position = "relative"
  el.style.top = `${shift}px`
}

A corrector that trusts its own corrections is the verifier with extra steps and a worse attitude.

Attempt three, and siblings that shove each other

87%. Thirty left on the homepage, and seventeen of those were the same shape: a block inside a flex row aligned to the end, marked in my own logs as successfully seated.

Seated, and then unseated. In a flex row with align-items: flex-end, every item is pinned to the bottom of the line, so making any one of them taller grows the line and moves all the others. I was walking the page in document order, correcting each block after the ones above it, which handles the fact that a correction pushes everything below it down. It doesn't handle a correction reaching sideways and lifting a sibling I'd already finished with.

I'd been treating layout as a list. It's a constraint system, and it will argue back.

The fix is embarrassingly short: sweep the page again, and again, until a sweep changes nothing. Each pass corrects whatever residue the last one introduced, so it converges fast. On this site it takes three.

  1. Homepage

    As shipped
    28 of 299
    After seating
    290 of 299
  2. The case study

    As shipped
    56 of 501
    After seating
    499 of 501
  3. The writing index

    As shipped
    13 of 61
    After seating
    61 of 61
RouteAs shippedAfter seating
Homepage28 of 299290 of 299
The case study56 of 501499 of 501
The writing index13 of 6161 of 61
Printed by e2e/seating.spec.ts on every run, which fails the build if seating stops raising the count above 95%.

The handful that remain are boxes neither lever can move, and they're reported as missed. I'd rather publish six failures than round them away, partly on principle and partly because the last time I let a tool flatter its own output it took me a fortnight to notice.

What an honest baseline grid actually costs

Seating the first line of a block does nothing for the second one. The second line sits exactly one line-height below the first, so if your line-height isn't a whole number of grid rows, line one is perfectly seated and line five has visibly wandered off. To fix that you have to snap the leading to the grid as well.

This is precisely what InDesign does when you tick align to baseline grid, and it's why that checkbox changes your leading the moment you turn it on. Everyone who has used it has had the same small moment of annoyance about that. It turns out the annoyance is load-bearing.

So the honest version of this quantises your leading and quantises the space between your blocks. That isn't a bug in my implementation. It's the trade print made on purpose a century ago, and it's most of the reason a well-set spread feels calm rather than busy. Calling it a cost rather than a defect is a fair chunk of the difference between a tool and a toy.

So I tried to do it properly, in CSS, on this site

Running a corrector at runtime is a demonstration. The real question is whether you can build a site that lands on the grid by construction, with no JavaScript involved at all. So I tried it here, on the site you're reading, and the answer turns out to be half yes in a way that's more interesting than either a yes or a no.

The tool had already told me the diagnosis and I hadn't listened properly. It reported the drift as scattered rather than systematic, across 125 distinct values, and its own documentation says what that means: scattered drift is the type scale and the spacing scale disagreeing. It wasn't a wrong origin, it was that nothing on the site was a whole number of anything.

Every vertical dimension was a clamp. Fluid spacing, fluid type, unitless leading multipliers on top. At 1280px the spacing scale resolved to 4.83, 9.66, 14.64, 19.48, 29.14, 38.84, 58.33, 77.66 and 116.5 pixels. Not one whole number in it. And that isn't sloppiness, it's arithmetic. A clamp is a continuous function of viewport width and a grid is a discrete lattice, so a fluid scale lands on a grid only by coincidence, at isolated widths.

Fluid spacing and a baseline grid aren't difficult to combine. They're opposites.

So the spacing scale became fixed multiples of the pitch, and the leading got snapped. That second part has a neat trick in it. CSS round() is exactly the snapLineHeight function from the tool, so one definition per token does the whole site.

/* ceil(preferred / pitch) * pitch, in CSS */
--leading-relaxed: round(up, calc(1.7 * 1em), var(--pitch));

The 1em resolves against whichever element uses the token, so five definitions snapped 145 usages, each one against its own font-size. Up, rather than to-nearest, for the same reason the tool does it. Rounding to nearest tightens the leading to reach a grid line, and a grid that costs you legibility has misunderstood its job entirely.

Pitch was a real decision rather than a default. I started at 8px, which is what every figure here quotes, and it made the type worse. 16px body text wants about 27px of leading, the next 8px row up is 32, and every reading column on the site gained 18% leading just to satisfy a ruler. At 4px the same line lands on 28 and nothing visibly moves. A finer pitch is an easier target, and I'd rather say that out loud than quietly bank the better number.

Rhythm is not phase

With every leading and every spacing token a whole number of rows, the site went from 13 of 232 on grid to 46. Which is better, and nowhere near what I expected, and the gap is the actual finding.

Seating text needs two separate things. Rhythm is the distances, line to line and block to block. Phase is where the first baseline sits inside its own box. Quantising the CSS gives you rhythm exactly. It gives you nothing at all for phase, because phase is half-leading plus ascent, and ascent belongs to the typeface at its rendered size. Every font and size pair on the page starts at its own offset.

I checked that rather than assuming it. If the page were only out of phase by one shared amount, moving the grid origin would fix the lot at once. So I swept the origin across all four sub-pixel positions and watched the count.

  1. 0.00px

    On grid, homepage
    46 of 233
  2. 1.25px

    On grid, homepage
    67 of 233
  3. 2.00px

    On grid, homepage
    68 of 233
  4. 3.25px

    On grid, homepage
    57 of 233
Grid originOn grid, homepage
0.00px46 of 233
1.25px67 of 233
2.00px68 of 233
3.25px57 of 233
The best origin reaches 29%. There is no single offset that seats the page, because there is no single phase to correct.

There's one CSS feature that attacks phase head on. text-box-trim removes the half-leading so the box edge becomes the baseline, and I tried it. It moved the homepage from 46 to 49. It does that by trimming to cap height, which is the actualBoundingBox metric this same project already measured as the one that doesn't survive a change of browser. The feature designed to fix phase is built on the number that's 8.8% different in Firefox. I took it back out.

CSS can give a page its rhythm. Only measurement can give it phase. That's the whole reason this tool has to exist.

So that's where this site sits, and it's stated plainly on the research card rather than rounded up. The rhythm is quantised and gated. Every prose leading is a whole number of grid rows, and the build fails if a new rule lands with a raw ratio in it. The phase isn't, and closing it is one run of quoin.seat() away, which takes the homepage to 228 of 233.

So I pointed it at everyone else

Everything up to here is about one site, which makes it an anecdote. The tool bundled to 14kB at the time with no dependencies, which means it can be dropped into any page, so I ran the same measurement across the reference design systems. Nine of twelve produced enough text to characterise. Reproduce it with npm run corpus.

Before the table, the framing, because the numbers are meaningless without it. None of these sites claims a baseline grid. Being off one isn't a defect. What the table describes is the state of the medium: a convention print has had since metal type, that the best resourced design systems in the industry don't have either, because the platform makes it genuinely hard rather than because nobody cared.

  1. Base Web

    Nodes
    140
    On a 4px grid
    86.4%
    On an 8px grid
    44.3%
    Distinct drifts
    19
  2. Atlassian Design

    Nodes
    95
    On a 4px grid
    63.2%
    On an 8px grid
    38.9%
    Distinct drifts
    18
  3. Ant Design

    Nodes
    96
    On a 4px grid
    45.8%
    On an 8px grid
    35.4%
    Distinct drifts
    24
  4. Tailwind CSS

    Nodes
    137
    On a 4px grid
    44.5%
    On an 8px grid
    33.6%
    Distinct drifts
    27
  5. Shopify

    Nodes
    137
    On a 4px grid
    43.8%
    On an 8px grid
    32.8%
    Distinct drifts
    49
  6. Bootstrap

    Nodes
    89
    On a 4px grid
    44.9%
    On an 8px grid
    32.6%
    Distinct drifts
    41
  7. Material Design 3

    Nodes
    118
    On a 4px grid
    57.6%
    On an 8px grid
    29.7%
    Distinct drifts
    9
  8. Nord Health

    Nodes
    61
    On a 4px grid
    49.2%
    On an 8px grid
    29.5%
    Distinct drifts
    35
  9. Apple Human Interface Guidelines

    Nodes
    61
    On a 4px grid
    44.3%
    On an 8px grid
    29.5%
    Distinct drifts
    24
  10. Chakra UI

    Nodes
    82
    On a 4px grid
    46.3%
    On an 8px grid
    29.3%
    Distinct drifts
    19
  11. Open Props

    Nodes
    1672
    On a 4px grid
    48.6%
    On an 8px grid
    27.9%
    Distinct drifts
    188
  12. Bulma

    Nodes
    155
    On a 4px grid
    43.9%
    On an 8px grid
    26.5%
    Distinct drifts
    61
  13. Mantine

    Nodes
    206
    On a 4px grid
    33.5%
    On an 8px grid
    17.5%
    Distinct drifts
    121
Design systemNodesOn a 4px gridOn an 8px gridDistinct drifts
Base Web14086.4%44.3%19
Atlassian Design9563.2%38.9%18
Ant Design9645.8%35.4%24
Tailwind CSS13744.5%33.6%27
Shopify13743.8%32.8%49
Bootstrap8944.9%32.6%41
Material Design 311857.6%29.7%9
Nord Health6149.2%29.5%35
Apple Human Interface Guidelines6144.3%29.5%24
Chakra UI8246.3%29.3%19
Open Props167248.6%27.9%188
Bulma15543.9%26.5%61
Mantine20633.5%17.5%121
Homepages at 1280×900, 0.5px tolerance, grid origin solved from each page, not pinned to zero. Measured 2026-08-29 with quoin 1.22.0. Design systems from the Quoin corpus. 13 of 35 rendered enough text to characterise; the rest are dropped rather than scored, because a page with forty text nodes gives a percentage that means nothing.

Nobody has a baseline grid. The best result on an 8px grid is Shopify Polaris at 29.8%, and on the finer 4px grid nothing clears half. If you have ever wondered whether the calm of a well-set magazine spread is achievable on the web by ordinary means, this is your answer: the people with the largest type teams in the industry haven't got there either.

The column that actually matters is the last one

Percentages tell you how far off a page is. Distinct drift values tell you whether it's off in an orderly way. Five distinct values across a whole homepage means the scales agree with each other and the grid is close, a matter of one origin and some care. Thirty means the type scale and the spacing scale are having different conversations.

GOV.UK, Polaris and Material all sit at five. Whatever else is true of those systems, their vertical rhythm is coherent, and you can feel that when you read them.

My site sits at ninety. It's the most scattered page in the corpus, by three times, and I found that out by building the instrument that says so.

That number is after the quantisation described above, which improved it from 125. The cause isn't mysterious: this site has far more distinct type styles than a disciplined design system permits itself, because it was drawn one section at a time by one person who liked each section. A design system is a set of constraints someone agreed to in advance. A portfolio is what happens without them, and now I've the measurement to prove it about my own work rather than a feeling about somebody else's.

The table above is left exactly as it was measured, because a survey you quietly edit after fixing your own entry isn't a survey any more. What happened next is a separate piece. I went after the ninety and got it down to ten, which moves this site from last in the corpus to fourth, and it took three changes. The first was one token being put back by a media query in another file.

And then do not ship it

One more thing, because it would be easy to get this far and then do the wrong thing with it. Correcting layout in JavaScript at runtime means the correction lands after first paint, which means your reader sits and watches the text jump into place. So you've fixed a half-pixel rhythm problem by introducing a visible one. Don't ship the script. Ship what it gives you.

So the tool exports the corrections as CSS. Measure in the browser while you're working, take the stylesheet, paste it in, delete the script. The browser is where the font metrics live, so that's where the measuring has to happen. It's not where the fix has to ship.

It's running on this page, if you want to try it. Open the console and type quoin.check() to see how far off this article is, or quoin.seat() to put it on the grid and watch what moves. Call seat again to undo it. I'd suggest a paragraph-heavy page rather than this one, since you're currently standing in a piece of writing that's about 40% tables and demos.

No share buttons, no claps, no newsletter thing sliding up from the bottom. If it's any good you'll send it to somebody yourself, and if it isn't then a row of icons was never going to fix that.