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.
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
Institutions
9
32.0%
2.2%
22
Type foundries
15
31.9%
3.7%
21
Academic
6
30.9%
12.6%
34.5
Design systems
27
30.8%
29.5%
21
Documentation
39
29.3%
20.9%
22
Studios
11
25.7%
8.1%
28
Editorial
20
25.0%
18.8%
46
Products
25
25.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.
Site
Before
Seated
Stylesheet alone
GOV.UK Design System
16%
100%
100%
Shopify Polaris
29%
100%
100%
Tailwind CSS
12%
100%
99%
Material Design 3
17%
100%
100%
Ant Design
21%
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
Name
Size
Leading
Space
Cap
Moved
h1
44px
48px
57.516px
30.484
exact
h2
27px
32px
29.297px
18.703
leading −0.4
body
17px
24px
27.828px
12.172
leading −1.5
lead
21px
32px
24.953px
15.047
leading +1.55
code
15px
24px
22.422px
9.578
exact
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.
Width
320
414
600
768
1024
1280
1440
On the grid
9/9
9/9
9/9
9/9
9/9
9/9
9/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.
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.
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.