Typography layouts
A specimen of every element that carries text, written about the thing it sets. Nothing here is labelled or arranged for effect: each element sits where it would sit in a document, and how it looks on this page is how it looks in yours.
Most quantitative typography rules in circulation are uncited convention. The 45–75 character measure, the 16 px minimum body size, the 1.5 line height, ratio-based type scales — each one, traced to its source, ends at a style manual or a design blog rather than a study. The evidence that does exist lives in vision science, expressed in degrees of visual angle, and web typography has largely never connected to it.
That gap is the reason this module separates what it enforces from what it merely chooses. Conformance rules are normative and tested. Measured bounds come from reading psychophysics. Everything in between is labelled convention and left to the author.
Six rules in wide circulation did not survive that separation. They are withdrawn here rather than merely out of fashion, and none may come back as a rationale for a value this module ships:
- Measure
-
45–75 characters is the readable range.A style-manual figure. What was actually measured is a floor near 13 characters, and a floor is not a range. - Body size
-
16 px is the minimum readable size.No study fixes a minimum in pixels. The evidence is angular, and the fluent band spans a factor of ten. - Leading
-
A line height of 1.5 is the readability optimum.1.5 is the figure WCAG 1.4.12 says content must tolerate — a conformance floor read as a design target. - Typeface
-
Harder-to-read type improves recall.The disfluency result did not hold up. - Weight
-
High-resolution screens make weight 300 safe for body text.Resolution is not the variable that decides it. - Scanning
-
Readers scan a page in an F-shaped pattern.An eye-tracking observation about particular unstructured pages, generalised into a law about layout.
Struck rather than deleted, and on a page about markup the distinction is
load-bearing: s marks a claim that is no longer accurate, while
del records an edit made to this document. The
correction under Measure is the second kind.
Measure
The number with the widest gap between confidence and evidence
Measure is the length of a line of text, counted
in characters. How that sentence usually continues, and how it continues
here, differ enough to be worth showing as the edit it is:
the readable range is 45 to 75
characters
the measured floor is about 13
characters, and everything above it is preference.
Where the 45–75 figure comes from
Follow a citation of it far enough and it stops at a style manual or a design blog rather than at a study — often The Elements of Typographic Style, which offers it as craft advice and claims nothing more for it. Nothing was falsified. A convention was repeated until it read as a finding, which is the failure this module exists to stop. The correction above was made on .
What is actually measured
The measured floor is roughly 13 characters per line — below that, reading speed falls away; at or above it, readers sustain about 80 per cent of their maximum rate. The figure is close to constant across typefaces and displays, and drops to about eight characters for readers with low vision. Everything above 13 is preference, aesthetics and scanning convenience rather than readability.
Print size behaves the same way. Reading rate peaks across a band of angular x-height (θx) running from about 0.2° to 2° — a factor of ten — and contrast polarity makes no measurable difference either way. Because that band is in degrees it incorporates viewing distance, which CSS pixels do not. Converting requires assuming how far the reader sits from the screen, and that assumption, not the science, is where the real uncertainty lives.
The measured floor for measure is ~13 characters, not 45. The permissible print-size band is ten times wide. That is why every style guide’s number is defensible and none is special.
This module ships 65 characters for body text. That is convention, and the
source says so. Against the two numbers that are not convention — the
measured floor below it and the WCAG 1.4.8 ceiling above it — it sits
here:
What the standards require
WCAG 1.4.8 sets a maximum of 80 characters, and it is worth being precise about what that is: a maximum, at level AAA, routinely confused with the 66-character “optimum” it has nothing to do with.
| Criterion | Level | Requires |
|---|---|---|
| 1.4.4 Resize Text | AA | Text resizes to 200% without loss of content or functionality |
| 1.4.10 Reflow | AA | No two-dimensional scrolling at 320 CSS px width |
| 1.4.12 Text Spacing | AA | No loss of content under the reader’s spacing overrides |
| 1.4.8 Visual Presentation | AAA | Line spacing ≥1.5×, width ≤80 characters, no justified text |
The first three rows are level AA and are the floor this module builds
to. The fourth is AAA, and its ceiling is the one figure here the
build enforces outright: measure() refuses any value
above 80 characters rather than emitting it.
|
||
1.4.12 defines what content must tolerate when a reader overrides spacing — not what a stylesheet should ship. Treating its 1.5 figure as a readability target mistakes a conformance floor for a design goal.
A measure does not rescue an unbreakable token. 1.4.10 forbids
two-dimensional scrolling at 320 CSS px, and one long word is
wider than the column at any measure — a hash, a file path, or
Rindfleischetikettierungs,
which carries a <wbr> at each compound seam. The module
answers that with overflow-wrap: break-word, which breaks the
word wherever the line runs out — on the selectors it maps, which is
every role-carrying tag and its class, plus li,
dt and dd. It reaches no further than that.
td and th appear in no selector the module emits,
so the cells of the table above are not covered, and neither are
caption, address, legend,
summary or output.
<wbr> is what an author adds when the break belongs
somewhere in particular.
Vertical rhythm
Baseline grids restrict where text baselines may fall. They exist for wide, multi-column layouts — newspaper pages — where uneven baselines between adjacent columns are visible to the reader. That failure mode barely occurs in a single-column, reflowing, variable-height layout, and the grid arrived on the web as an aesthetic principle detached from the problem that motivated it.
Why it is hard in CSS
CSS has no concept of a baseline. Each line of text sits roughly in the middle of its line box, with the difference distributed as half-leading above and below, so baseline position is an emergent consequence of font metrics and line height rather than something you can address directly.
What breaks rhythm in practice
- Images and embedded media of arbitrary height
- Variable-length content that cannot be measured in advance
- The vertical jump when a web font replaces its fallback and ascent, descent and line-gap all change at once
What changed technically
-
text-box-trimandtext-box-edgeremove the invisible half-leading, making the visual text edge addressable rather than emergent. -
@font-facemetric overrides —size-adjust,ascent-override,descent-overrideandline-gap-override— force a fallback to adopt the intended font’s metrics, targeting the swap jump directly.
Support floor
Both capabilities are recent. A package has to decide whether it degrades or
requires, and this one degrades: the trim mixin sits behind
@supports and does nothing where unsupported.
A note on scales
Whether ratio-based scales beat arbitrary hand-picked steps on any reading outcome appears never to have been studied. It is untested, not disproven. Ratios remain a reasonable authoring convention with an aesthetic rationale — which is a legitimate reason to use one, and not an evidentiary one.
// A step on the modular scale. Convention, not evidence.
@use '@erikakers/typography' as type;
.callout {
font-size: type.scale(2); // 1.5625rem at the default ratio
max-inline-size: type.measure(65);
}
measure() with a value outside 13–80 ch
fails the build rather than emitting it.
Units
rem-
Resolves against the document root. Respects the reader’s
default-font-size preference, which
pxdoes not. This is the real accessibility argument for it, and it is narrower than the one usually given. em- Cascades against the parent and compounds when nested. Correct for spacing that should grow with the text it surrounds; wrong for a type scale that must stay stable inside a document.
ch- Resolves against the element’s own font, which is why measure belongs on a container rather than on every paragraph.
vw- Does not scale with browser zoom. That is an accessibility failure rather than an aesthetic preference, and the module refuses the unit outright.
A correction worth making carefully: page zoom scales px
proportionally and triggers media queries as expected, so the common claim
that px breaks zoom
is wrong. Press Cmd + + on
macOS, or Ctrl + + elsewhere, to confirm —
everything on this page grows, including anything sized in px.
The real failure is narrower than that, and it belongs to the
browser’s default-font-size preference rather than to zoom. Raise that
setting from 16 px to 20 px and inspect two elements. One declared
font-size: 16px still reports a computed 16px: a
px size ignores the preference outright. One declared
font-size: 1rem now reports 20px, because
rem resolves against a root the preference has moved. That
difference is the whole accessibility argument for rem, and it
is enough on its own.
Stating it the wrong way costs credibility with people who know better.1
Scripts
No W3C script layout document — CLREQ, JLREQ, ALREQ or ILREQ — specifies a line-height value. The CJK leading figures in circulation have no standards basis. What the standards do establish is structural, and it is enough to make a single global line height tuned on Latin a defect for at least three scripts.
Japanese
日本語の組版では、文字は正方形の枠に収まります。ベースラインではなく枠が基準になるため、同じフォントサイズでもラテン文字と同じ大きさには見えません。
CJK text aligns to a square character frame rather than a baseline, and
font-size is that frame’s side length. Latin and CJK at
equal font-size are therefore not optically equal.
The reading above the first word is a ruby annotation, and it is worth
watching rather than reading: the rt text occupies a box above
the line, so the line that carries it is taller than the lines under it and
the paragraph’s spacing stops being even. The parentheses in the
rp elements are the fallback a browser without ruby support
shows, and are hidden wherever the annotation renders properly. A leading
value chosen on Latin text has no idea the annotation is there.
Chinese
王羲之的书法是这里的例子:专名号是一条实线,画在人名或地名的下面。
The line under the name in that sentence is neither emphasis nor a link. In
Chinese typesetting the proper-name mark is a rule drawn beneath a personal
or place name, and it is the case that survives the general advice never to
underline anything that is not a link — an annotation whose meaning is
carried by the line itself, which is what u is for.
Devanagari
देवनागरी लिपि में अक्षर शिरोरेखा से लटकते हैं। इस लिपि में एक्स-हाइट नहीं होती।
Devanagari hangs from a headline stroke, the
shirorekha, and has no x-height at all
— so font-size-adjust has no target here. The module
raises a build error rather than emitting it for this script.
Arabic
تمتد الأحرف الصاعدة والنازلة في الخط العربي أبعد مما تمتد في اللاتينية، وقد تتصادم عند استخدام تباعد أسطر مضبوط على اللاتينية.
Arabic ascenders and descenders extend further than Latin, risking collision
at Latin leading. The module scopes a leading override by
:lang() — and the comment in the source says plainly that
it is choosing that value, because the standards bodies decline to
specify one.
Direction
Direction is a separate axis from script, and two elements exist for the places it goes wrong. Consider a comment list whose display names come straight out of a database, so their direction is unknown to whoever wrote the template: منى, Ada Lovelace and الخوارزمي. Wrapped as منى, Ada Lovelace and الخوارزمي, each is isolated, and no name can drag the comma after it to the wrong end of the line.
bdo is the opposite instruction. It overrides the bidirectional
algorithm outright instead of isolating from it:
this clause is Latin text forced to lay out right to
left. It is a rendering override rather than a translation, and it is
almost never what a page wants — which is the reason to see one once,
in running text, rather than to reach for it.
A dialog that is not open is display: none, so
everything above is what a reader gets only once something has opened it.
Type inside a dialog is type, and it inherits the same tokens as the page
that raised it.
Corrections to anything above belong on the issue tracker, not in this file