Quoin 1.1 · MIT · no dependencies · 26 kB

It puts a web page on a baseline grid.

A quoin is the wedge a printer hammers into the chase to lock the type so nothing shifts on the press. Same job. Print has had this since metal type. The web has never had it at all.

measuring the specimen…

A page like any other: a fluid type scale, a spacing scale in round numbers, leading set as a ratio. Nothing on it is a mistake.

The letters were never centred

Every font reserves a band of empty space above its capitals and another below its baseline for descenders, and those two bands are almost never the same size. The browser measures the font's box, subtracts it from the line height, and splits what is left equally. So it centres the box perfectly. The letters inside that box were never centred.

Print solved this with the baseline grid: one invisible ruler down the page, and every line of text in every column seated on it. It has existed since metal type and it is a checkbox in InDesign.

The web has never had one, because line-height centres text in its box rather than seating it on a line, so the baseline lands wherever a particular typeface's asymmetry puts it. There is no number you can write down once.

Rhythm

Every vertical distance a whole number of grid rows. CSS gives you this exactly: fix the spacing scale to multiples of the pitch and snap the leading with round().

Phase

The first baseline inside each box landing on one. Half-leading plus ascent, and ascent belongs to the typeface at its rendered size. CSS gives you nothing.

So: CSS for the rhythm, this for the phase.

Both of those are the vertical. A baseline grid sits inside a column grid and the column grid is the half a designer usually means by the word, so quoin columns measures that too. It has the same defect and the same cause: 1104px divided three ways with 40px gutters is a module of 341.33px, every division after the first sits on a fraction, and no care taken with the markup moves it because the container width settled it before any markup existed.

This page was 15.4% aligned on a fractional module when the check was written. The gutter is 48px now, which divides the shell whole at two, three, four and six columns, and which is six grid rows: the two axes are measured in the same unit rather than in two conventions that happened to sit next to each other. and you can see the one this page is set on.

Nobody's site is on a baseline grid

Two hundred and twelve sites, measured at 1280px with Quoin: design systems, documentation, editorial, type foundries, studios, products, museums and universities. 152 rendered enough to score.

Not one of them reaches 90%. The median is 28%.

Medians by category. 1280×900, half-pixel tolerance, grid origin solved per page. Reproduce with npm run corpus.
Category Sites 8px grid Rhythm Distinct drifts
Institutions932.0%2.2%22
Type foundries1531.9%3.7%21
Academic630.9%12.6%34.5
Design systems2730.8%29.5%21
Documentation3929.3%20.9%22
Studios1125.7%8.1%28
Editorial2025.0%18.8%46
Products2525.0%25.4%43

None of these teams claims a baseline grid, so being off one is not a defect. The table describes the medium rather than the teams: a convention print has had since metal type, which nothing on the web has.

The categories are the same, and that is the finding. Best to worst runs 32.0% to 25.0%. Type foundries, whose entire trade is typography and who sell the fonts everyone else sets, land where the documentation sites land. Knowing more about type does not put a page on a grid, because the thing in the way is not knowledge.

Except in one column. Rhythm is whether each box is a whole number of grid rows tall. Design systems median 29.5% against 3.7% for the foundries, eight times better and the largest gap in the study. That is exactly what a design system is for, and it buys them nothing on phase, where they sit mid-table.

Quantising your CSS gives you rhythm. Rhythm is not phase. The teams doing the most disciplined vertical spacing on the web are doing it correctly and still landing where everyone else lands, and that gap is the whole reason this exists.

Leading is the cause on 104 of the 152. A line-height that is a ratio rather than a number of rows. 1.5 on 17px is 25.5px, and every extra line carries the half pixel down the page. It is the least visible defect available, because 1.5 looks like a decision.

What it actually does

Seating one element is arithmetic, and several tools do it well. Seating a page is a constraint problem, and that is the part nobody had built.

Correcting a block moves every block below it

So the corrections cannot be computed in a batch and then applied. Each block has to be measured after the ones above it are already corrected. Compute-then-apply gives you a perfect first paragraph and a page that is worse than when you started.

Correcting a block can move blocks above it too

In a flex row under align-items: flex-end, making any item taller grows the line's cross size and shifts every end-aligned sibling. Seating the third item lifted two already seated ones back off, which cost 17 blocks on one homepage before it was caught. So it sweeps until nothing moves.

It checks its own corrections

padding-top is the lever you want, because it moves the text and pushes everything below it down. It does not always work: a flex child under align-items: center grows when you pad it and the container re-centres the bigger box, so the text moves half as far as you asked. So it applies the padding, measures again, and reverts to a relative offset if the text did not land where it was sent. If neither lever works the block is reported as missed rather than counted as fixed.

A corrector that trusts its own corrections is the verifier with extra steps.

And it checks the stylesheet it gives you

Pointed at five design system homepages, four reproduced the seating from the exported CSS alone. Material Design 3 went from 123 of 123 with the script to 18 of 123 with the stylesheet: nine line-height declarations lost the cascade to Angular's four-component scoped selectors, and a block whose leading stays 2px short moves everything below it. So the export applies itself, measures every declaration, and adds !important to exactly the ones that lost.

Seat, export, undo, inject the stylesheet alone, measure again. npm run wild.
SiteBeforeSeatedStylesheet alone
GOV.UK Design System16%100%100%
Shopify Polaris29%100%100%
Tailwind CSS12%100%99%
Material Design 317%100%100%
Ant Design21%98%98%

Or keep your design and move the grid to it

Everything above is remedial. It measures a page that is off the grid and pushes each block into place, and what you get back is a list of pixel corrections that describe one arrangement of line breaks. There is a better answer, and it does not ask you to change a single size.

Trim the boxes. Under text-box-trim: trim-both with text-box-edge: cap alphabetic, a block starts at its cap height and ends at its baseline, so the distance from one baseline to the next across a block boundary is

(lines − 1) × leading + space + cap

lines is the only term that changes with the viewport, and it is multiplied by a leading that is already a whole number of rows. So modulo the pitch what is left is space + cap, and every term in that belongs to one block by itself. Nothing relates one size to another. The sizes are free.

So a design keeps every size it asked for. The leading is snapped to the nearest whole number of rows, which is the one thing that moves and is the same thing InDesign moves when you tick the box. The space before each block is then solved to close that block's own cap height.

npx quoin fit --design design.json · three families, five sizes, nothing moved but the leading
NameSizeLeadingSpaceCapMoved
h144px48px57.516px30.484exact
h227px32px29.297px18.703leading −0.4
body17px24px27.828px12.172leading −1.5
lead21px32px24.953px15.047leading +1.55
code15px24px22.422px9.578exact

It holds at every width, with no media queries

A page built from a fit, with headings that wrap at some widths and not others, a list, a blockquote and paragraphs reflowing from two lines to nine. One stylesheet, nothing to regenerate when the copy changes.

Measured in Chromium and WebKit. Two controls run beside it, the same page with the design's own spacing and the same page without the trim, and both have to fall below 75%. Both do.
Width320414600768102412801440
On the grid9/99/99/99/99/99/99/9

Fluid type as well, which this site used to say was impossible

A clamp() size varies continuously, so its phase does too, and it lands on the grid only where it happens to. That was the argument and it was right as far as it went. What it missed is that the space does not have to be a number: CSS has mod(), so the browser can do at layout time what the fitter does at build time.

margin-top: calc(6 × var(--pitch) − mod(var(--cap), var(--pitch)))

Eleven widths from 320 to 1440: a clamped heading goes from on the grid at no width to on the grid at every one. The only thing that cannot be fluid is the leading, because it has to be a whole number of rows and there is no continuum of whole numbers.

What it costs the rest of the web

Every site in the survey had its design read off its own page and fitted. The median would move 15.6px of leading across 8 sizes, which is under two pixels a size, and no size ever changes. The largest single change anywhere in 175 sites is about four pixels.

The interesting half is what makes a site expensive. Rollup could be fitted for nothing at all and Deno would cost sixty-seven pixels, and that is not because Deno's typography is worse. Deno sets twenty-nine distinct size-and-leading combinations on one page and Rollup sets five. Every one of Deno's changes is under four pixels, like everybody else's. There are simply twenty-nine of them.

The grid is not asking you to accept type that looks different. It is asking how many sizes you actually needed.

And it checks the font actually loaded

A fit solved against a font that did not render describes a typeface nobody is going to set in, and the obvious check does not work: ctx.font reads back the family you asked for, so a font nobody has installed hands its own name straight back while the measurement comes off the fallback. It probes widths against two different fallbacks and warns in the stylesheet rather than quietly lying.

Three things found by building it

A tool that measures browsers ends up measuring browsers. All three are reproducible from the repository, and the raw output is committed.

Cap height travels now

Both readings are called cap height and only one of them is portable

Canvas reports the drawn glyph: Chromium and WebKit hinted onto the pixel grid, Firefox as a scaled outline. CSS text-box-edge: cap reports the font's own sCapHeight.

font table
130 / 130
spread 0.022px
canvas
90 / 130
spread 0.864px

Proving why needed a font that lies. Every real font sets sCapHeight to the height of its own H, so 21 real fonts could not tell "reads the table" from "measures the glyph". A manufactured Space Mono declaring 600 where its H is 700 reports 10.797px in all three engines. They read the table.

A generic keyword is not a font

fontBoundingBox agrees exactly, and the exceptions are not exceptions

Across three engines it agreed to the pixel for every font loaded from the same bytes. It disagreed on monospace and system-ui, which are the two rows where the engines resolved different typefaces.

same font
0.04px
monospace
27.16px
system-ui
14.14px

system-ui is a promise, not a typeface. So, it turns out, is monospace.

Found in the discard pile

WebKit does not apply automatic optical sizing

Two fonts kept failing a validity check, and they were the only two in the corpus with an opsz axis. At 48px with font-optical-sizing: auto, which is the default:

Inter, Chromium & Firefox
460.88
Inter, WebKit
483.59

Set font-optical-sizing: none and all three agree to 0.03px. A variable font with an opsz axis is a different instance in WebKit at display sizes. Not a measurement problem. A different font.

Six ways in

The library has no dependencies. Fitting from font files needs nothing at all; everything that measures a rendered page drives a real browser, so it needs Playwright and says so plainly if you have not got it.

Fit a design

npx quoin fit --design design.json

Keeps every size, snaps the leading, solves the spacing. Name each family's font file and it never opens a browser: three families in 76ms, which is what makes it usable in a build.

Fit a site you already have

npx quoin fit --from https://example.com

Reads the design off the rendered page, groups every block by what it actually resolved to, and fits that. Most people have a site rather than a design file.

Measure and correct

npx quoin check https://example.com
npx quoin rhythm https://example.com
npx quoin seat https://example.com -o baseline.css

For a page that cannot be rebuilt. --min 90 exits non-zero, and the GitHub Action gates on a committed baseline.

On the page in front of you

quoin.check()
quoin.seat()
quoin.css()

Paste the bundle into any console, or install the extension and press a button. Both reach pages a hosted service cannot.

In your build

npm install quoin

fitScale, inferDesign, verifyGrid, verifyRhythm, seatPage. ESM, CJS and types, no dependencies.

For an agent

cat design.json | npx quoin fit --design - --json

Takes the shape you already have, whether that is a Figma export or a flat token file. Every error names the entry it is about, because an agent cannot ask a follow-up question.

Where a correction holds, and where a fit is the answer instead

This page is corrected at build time, once per breakpoint, and the stylesheet is 5 kB. That arrangement predates the fitter and it is the clearest demonstration of why the fitter exists.

Measured properly, a correction survives more than it was given credit for. A stylesheet seated at 1280 and carried to 375 held at 100% when only the line breaks had moved, because snapping every leading to whole rows means an extra line adds a whole number of rows and nothing shifts. It fell to 0% when a media query changed a container's padding by thirteen pixels.

Corrections survive reflow and do not survive a layout change, and every real site has one. This page changes its layout at six ranges rather than merely its line breaks, so a correction taken in one range is describing a different page in the next.

A fitted design needs none of it, because there is nothing to carry. Rebuilding this page on a fit is the obvious next thing and is not done yet, so that is a description of this site rather than a recommendation. The score in the footer is measured live, which means it tells you what you are actually looking at rather than what the author hoped for.

Why an extension and not a paste-your-URL box

A page's Content-Security-Policy governs script tags injected from outside, and a hosted service has no other way in. The two most famous URLs anyone would paste are the two it could not measure.

Route inStripeGitHubKlimLinear
A <script> tag, which a hosted service must usenonoyesyes
chrome.scripting, which the extension usesyesyesyesyes