Skip to content
@erikakers/typography

Specimen page. The .demo-* classes are the frame. Everything inside it is the module and the browser, undisguised — where the module says nothing, what you see is the browser.

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: 65 characters, in a band running from 13 to 80

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.

The four criteria that bear on text layout
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überwachungsaufgabenübertragungsgesetz, 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

  1. text-box-trim and text-box-edge remove the invisible half-leading, making the visual text edge addressable rather than emergent.
  2. @font-face metric overrides — size-adjust, ascent-override, descent-override and line-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);
}
Calling 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 px does 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 non-modal dialog, left open, needing no script to render. The border, the padding and the shrink-to-fit width around this paragraph are all user-agent defaults, and the paragraph itself is an ordinary p, so the type inside the box matches the type outside it. One thing here is the page’s doing and not the browser’s: position. A user agent takes an open non-modal dialog out of flow, and .demo-dialog puts it back so the box does not land on top of the next paragraph. That is layout rather than type, which is why it is allowed to be chrome.

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.

Maintained by @erikakers/typography
Corrections to anything above belong on the issue tracker, not in this file

The bottom of the scale

Body sits on step 0 and every heading above it, which is how the small-end crowding a geometric scale otherwise has is answered structurally. Below body there are two rungs and no more: caption at step −1, and fine at step −2, the small-text layer an interface has under its captions. A navigation link, an eyebrow, a table header, a byline, a footnote.

This paragraph carries .type-fine. It is a role rather than a size: it arrives with its own leading, its own tracking (opened by exactly one more step than caption’s, because the tracking curve runs off the size and not off a per-role table), its own measure, its own block margins, and a --type-size-fine custom property to retheme it by. Reaching past the roles for type.scale(-2) gets the size and none of that.

The scale stops there, and the reason is measured rather than chosen. The fluent range for continuous reading, the band of print sizes over which reading speed is at its maximum, runs from 0.2° to 2° of angular x-height, a factor of ten, which the source converts at its own 40 cm reading distance to 4 and 40 points. The top of that band is the ceiling every oversized heading on this page is bent toward. The bottom of it works out at about 10.26 px at a 16 px root, and at this module’s own reference ratio that is where step −2 lands. Step −3 would be a fifth under it.

Nothing enforces that floor, and that is deliberate. The bound is angular, so a CSS length only crosses it once you assume how far the reader is sitting from the screen, and that assumption, not the science, is where the uncertainty lives. It is the one number on this page that is real, cited, and still not a guard. Compare Measure, where the measured floor is a guard: a count of characters means the same thing at any viewing distance, and a font size does not.

Element and class parity

A class exists here for one reason: to let an element take a role’s appearance without taking its place in the document outline. Tag and class are emitted as one selector list — h2, .type-h2 { … } — so whatever carries the class receives exactly what a bare h2 receives, because it is the same declaration block. The two cannot drift, and specificity settles the rest: a class is (0,1,0) against a type selector’s (0,0,1), with no !important and no dependence on which stylesheet loaded first.

Both halves of that are below, in running flow rather than in a table of examples, because the thing worth checking is not the size — it is whether the rhythm holds around them.

Collapsing margins, not sibling selectors

The heading above is a bare h2 with no class attribute at all, and this is a bare p. The space between them is the heading’s trailing flow and the paragraph’s leading lead, collapsed to the larger of the two rather than added together, which is why every block on this page can carry both halves without the gaps compounding.

A line that looks like a heading and is not one

That line is a div carrying .type-h2. It is the same size, the same weight and the same distance from its neighbours as the real heading above it, because it is the same rule — and it appears nowhere in the outline, so a screen-reader user is never offered it as somewhere to navigate to. That is what to reach for when a design wants heading-sized text for something that is not a heading, and it is strictly better than the alternative of promoting the text to an h2 it has not earned.

The reverse case is the more common one, and the syndicated card below is where it turns up. An article pulled in from somewhere else arrives with a title that was the most important thing where it came from and is a minor thing here. It takes the rank the host document has room for — an h3, a level under the headings around it — while still needing to read as the title of a card rather than as a third-level heading.

Measure belongs to a container

No element rule in this module sets max-inline-size, and that is deliberate. A paragraph inside a card, a table cell or a sidebar has to fill the box it was put in; a measure pinned to the paragraph itself would fight every layout it ever met. The container opts in instead — the article at the top of this page carries it — and the paragraphs inherit a column rather than each inventing one.

That heading is an h3 wearing .type-h2. It keeps its own place in the outline while rendering at h2 size, the class wins on specificity alone, and the space above and below it is h2 spacing too, because every property the role owns travels together.

It is an h3 rather than a second h1 because a specimen for an accessibility-driven module does not get to break heading order to make a point, and the article around it does not license one either. The document outline algorithm was removed from the HTML specification, and no assistive technology ever derived heading levels from sectioning elements — so nesting adjusts nothing, and a second h1 here would be exactly what a screen reader’s heading list would show: a second level-one heading in the middle of a document. Nothing is lost by declining it. The demonstration is a class at (0,1,0) beating a type selector at (0,0,1), and that arithmetic is the same at every rank.

Read the last several blocks as one run of text and nothing jumps: a real h2, a div impersonating one, an h3 impersonating one, and ordinary paragraphs between them, all on the same rhythm. That is the parity guarantee doing its job, and it is also why so little else on this page carries a class. The lede at the top uses .type-lead, which names no tag HTML has; the article carries .type-prose for its measure, which belongs to a container rather than to an element. Ordinary text needs neither, and asks for nothing.

Weight

Every role on this page already carries a weight — the headings bolder, the running text normal — and until now that was the whole of it: two values, no way to put a semibold on a piece of body text without writing font-weight: 600 by hand somewhere nothing governs. The scale below is the answer, and it is a scale you configure rather than a set this module chose: you name the weights your type actually has, and each one arrives as a --type-weight-<name> custom property and a class to apply it with.

.type-weight-regular, which is 400. It looks exactly like the paragraph above it, and it should: a scale that named a weight the surrounding text already has would be a distinction with nothing behind it. What it buys is a name to reach for from a component.

.type-weight-medium, which is 500.

.type-weight-semibold, which is 600.

.type-weight-bold, which is 700.

Whether any of those four look different from one another is a fact about the font, not about the module. This page names no font family at all, so the four resolve against whatever the browser gives an unstyled document, and a stack with two faces will render 400 and 500 the same. That is CSS font matching resolving a weight to its nearest available face, and it is why the shipped scale stops at four names rather than listing the specification’s nine.

A heading-sized line, deliberately not bold

That line is the case the whole design turns on. It is a div carrying both .type-h2 and .type-weight-regular, and which of those two wins had to stop being a question about which stylesheet loaded first. font-weight is a property the parity rule sets at (0,1,0), so a weight class written once would tie with .type-h2 exactly, and a tie is settled by source order. The class is therefore emitted twice — .type-weight-regular.type-weight-regular, (0,2,0) — the same repetition .type-prose carries and for the same reason. Everything else the role owns is untouched: the size, the leading, the tracking and the block margins above and below that line are still h2’s.

And the same thing on a real heading

That one is a bare h2 wearing the weight class, and it answers identically, because (0,2,0) beats a type selector as comfortably as it beats a class. Both sides of the parity group get the same answer, which is the property that makes the weight class safe to add to a group it does not belong to.

Case, and the tracking capitals need

Section label — that is .type-uppercase, and the thing to look at is the space between the letters rather than the letters themselves. Capitals carry no ascenders, no descenders and no x-height variation, so the cues that separate one letterform from the next at ordinary tracking are simply not there, and the long-standing remedy is to open the tracking. The class used to emit text-transform and nothing else, which meant every uppercase label this module could produce was set at the tracking of the lowercase text it replaced.

That remedy is convention and is labelled convention. Nothing in the research behind this module measures tracking on capitals, so the number is chosen rather than derived, and it is yours: $tracking-uppercase and $tracking-small-caps, emitted as --type-tracking-uppercase and --type-tracking-small-caps, both 0.05em here because this page configures nothing.

The same label without it would sit at body tracking, which is the state the two lines below are for: .type-lowercase and .type-capitalize leave lowercase letterforms on the page and compensate nothing, deliberately.

.type-capitalize capitalises every word of the ring, including the small ones

Small capitals take the same treatment, from a setting of their own.

Two settings rather than one, and the reason is mechanical: font-variant-caps: small-caps uses a font’s true small-capital glyphs where the stack has them and synthesises scaled-down capitals where it does not. Designed small caps are drawn with their sidebearings already opened; synthesised ones are capitals at a smaller optical size. This page names no font family, so what you are almost certainly looking at is the synthesised kind.

A heading-sized label, uppercase

That line is the case this design turns on, and it is the weight section’s case again with a different property. It is a div carrying both .type-h2 and .type-uppercase, and letter-spacing is a property the parity rule sets — so a case class written once would tie with .type-h2 exactly, and a tie is settled by source order. The class is therefore emitted twice, .type-uppercase.type-uppercase, (0,2,0). Note what that costs and what it does not: the tracking on that line is the uppercase value rather than h2’s own, because a class cannot know which role it landed on — and the size, the leading, the weight and the block margins are still h2’s.

And the same thing on a real heading

A bare h2 wearing the case class, answering identically, because (0,2,0) beats a type selector as comfortably as it beats a class.

Text-spacing overrides

The button below is built with safe-box(): a min-block-size and padding in em rather than a fixed height with overflow: hidden. Apply the WCAG 1.4.12 overrides — line height 1.5×, paragraph spacing 2×, letter spacing 0.12em, word spacing 0.16em, all at once — and its label must still be fully visible.

A form, because that is where an override usually stops

Form controls do not inherit type by default, so a reader’s spacing override arrives at the page and halts at the control — the text they most need to read is the text that does not move. The reset answers that with font: inherit, plus letter-spacing and word-spacing, which the font shorthand does not carry and which are two of the four adjustments 1.4.12 defines. Set the four overrides and every control below that renders text — the select, the textarea, the text input above — has to grow with them. The progress bar and the range slider draw no text, so three of the four adjustments have nothing to act on there, and a user-agent-drawn track is not reached by font: inherit at all.

Reading preferences

65

63 of 80 characters

  • This page ships no JavaScript, so the output and the progress bar keep the values they were authored with, and the slider moves nothing but itself. The two commands in the menu above are the ones that work without a script: a reset button, and the popover you are reading.

    Notes

    1. The narrow claim is decisive on its own; the broad one is false and invites dismissal of everything around it. Back