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.
fontBoundingBox, the baseline
- Chromium
- same
- Firefox
- same
- WebKit
- same
actualBoundingBox, cap height, whole pixels
- Chromium
- 42 of 42
- Firefox
- 0 of 42
- WebKit
- 42 of 42
| Metric | Chromium | Firefox | WebKit |
|---|---|---|---|
| fontBoundingBox, the baseline | same | same | same |
| actualBoundingBox, cap height, whole pixels | 42 of 42 | 0 of 42 | 42 of 42 |
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.
| Size | Cap height | Whole 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.
Homepage
- As shipped
- 28 of 299
- After seating
- 290 of 299
The case study
- As shipped
- 56 of 501
- After seating
- 499 of 501
The writing index
- As shipped
- 13 of 61
- After seating
- 61 of 61
| Route | As shipped | After seating |
|---|---|---|
| Homepage | 28 of 299 | 290 of 299 |
| The case study | 56 of 501 | 499 of 501 |
| The writing index | 13 of 61 | 61 of 61 |
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.
0.00px
- On grid, homepage
- 46 of 233
1.25px
- On grid, homepage
- 67 of 233
2.00px
- On grid, homepage
- 68 of 233
3.25px
- On grid, homepage
- 57 of 233
| Grid origin | On grid, homepage |
|---|---|
| 0.00px | 46 of 233 |
| 1.25px | 67 of 233 |
| 2.00px | 68 of 233 |
| 3.25px | 57 of 233 |
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.
Base Web
- Nodes
- 140
- On a 4px grid
- 86.4%
- On an 8px grid
- 44.3%
- Distinct drifts
- 19
Atlassian Design
- Nodes
- 95
- On a 4px grid
- 63.2%
- On an 8px grid
- 38.9%
- Distinct drifts
- 18
Ant Design
- Nodes
- 96
- On a 4px grid
- 45.8%
- On an 8px grid
- 35.4%
- Distinct drifts
- 24
Tailwind CSS
- Nodes
- 137
- On a 4px grid
- 44.5%
- On an 8px grid
- 33.6%
- Distinct drifts
- 27
Shopify
- Nodes
- 137
- On a 4px grid
- 43.8%
- On an 8px grid
- 32.8%
- Distinct drifts
- 49
Bootstrap
- Nodes
- 89
- On a 4px grid
- 44.9%
- On an 8px grid
- 32.6%
- Distinct drifts
- 41
Material Design 3
- Nodes
- 118
- On a 4px grid
- 57.6%
- On an 8px grid
- 29.7%
- Distinct drifts
- 9
Nord Health
- Nodes
- 61
- On a 4px grid
- 49.2%
- On an 8px grid
- 29.5%
- Distinct drifts
- 35
Apple Human Interface Guidelines
- Nodes
- 61
- On a 4px grid
- 44.3%
- On an 8px grid
- 29.5%
- Distinct drifts
- 24
Chakra UI
- Nodes
- 82
- On a 4px grid
- 46.3%
- On an 8px grid
- 29.3%
- Distinct drifts
- 19
Open Props
- Nodes
- 1672
- On a 4px grid
- 48.6%
- On an 8px grid
- 27.9%
- Distinct drifts
- 188
Bulma
- Nodes
- 155
- On a 4px grid
- 43.9%
- On an 8px grid
- 26.5%
- Distinct drifts
- 61
Mantine
- Nodes
- 206
- On a 4px grid
- 33.5%
- On an 8px grid
- 17.5%
- Distinct drifts
- 121
| Design system | Nodes | On a 4px grid | On an 8px grid | Distinct drifts |
|---|---|---|---|---|
| Base Web | 140 | 86.4% | 44.3% | 19 |
| Atlassian Design | 95 | 63.2% | 38.9% | 18 |
| Ant Design | 96 | 45.8% | 35.4% | 24 |
| Tailwind CSS | 137 | 44.5% | 33.6% | 27 |
| Shopify | 137 | 43.8% | 32.8% | 49 |
| Bootstrap | 89 | 44.9% | 32.6% | 41 |
| Material Design 3 | 118 | 57.6% | 29.7% | 9 |
| Nord Health | 61 | 49.2% | 29.5% | 35 |
| Apple Human Interface Guidelines | 61 | 44.3% | 29.5% | 24 |
| Chakra UI | 82 | 46.3% | 29.3% | 19 |
| Open Props | 1672 | 48.6% | 27.9% | 188 |
| Bulma | 155 | 43.9% | 26.5% | 61 |
| Mantine | 206 | 33.5% | 17.5% | 121 |
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.