Dev & EngARTICLE

Modern typography reset leverages container queries and variable fonts in pure CSS

Modern typography reset leverages container queries and variable fonts in pure CSS
Image: Carina Ferreira
Article · Dev & Eng
Reading
8 min
Tables
1
Code
5
In short

A reset published by CSS-Tricks, called tidy-typography.css, brings together clamp(), container query units, and experimental text properties to handle line breaking, hyphenation, and fluid typography directly in CSS.

CSS resets traditionally handle zeroed margins and box-sizing, leaving typographic decisions to the project's CSS or to some orphan-correction script. CSS-Tricks published a reset called tidy-typography.css that brings together, in a single file, choices that can now be made with CSS alone: line breaking without a stray word, typography tied to the container's size (not the screen's), configurable hyphenation, and fine-tuning for variable fonts. The idea isn't to import the whole file: it's to copy the declaration that solves the problem at hand, because part of the set is still experimental and browser support is uneven.

For front-end developers, the practical gain is swapping UI JavaScript (heading-balancing libraries, manual hyphenation, language detection for quotes) for native properties that the browser already understands or simply ignores when unsupported. This graceful degradation is the reset's central point: no declaration breaks the layout in an unsupported browser, it just fails to apply.

Rendering and fallback before any font loads

Before talking about font size, the reset covers the basics of how text is drawn on screen:

css
html {
  -webkit-font-smoothing: antialiased;
  text-rendering: optimizeLegibility;
  font-synthesis: none;
  text-size-adjust: none;
}

body {
  font-size-adjust: from-font;
}

font-synthesis: none keeps the browser from faking bold or italic when the typeface doesn't have that style. text-rendering and font-smoothing are rendering hints, mainly for WebKit, and the result varies by platform. text-size-adjust: none, in turn, disables automatic font inflation on mobile, which can make a deliberately calculated layout jump in size on screen rotation, while font-size-adjust: from-font uses the font's own metrics to reduce that jump during a webfont swap.

Fluid typography that doesn't miss the container's size

Viewport-based fluid typography (vw) doesn't know the width of the element it's in: a heading in a narrow sidebar ends up scaling as if it occupied the whole screen. The reset's solution swaps vw for cqi, the container query unit:

css
.article-container {
  container-type: inline-size;
}

:root {
  --step-0: clamp(1rem, 0.9rem + 1cqi, 1.125rem);
  --step-3: clamp(2rem, 1.6rem + 3cqi, 2.75rem);
}

container-type: inline-size turns the wrapper into a query container, and children start scaling based on that container's width, not the screen's. The same heading behaves correctly in an article column or in a 300px sidebar, without a component-specific media query. In my reading, it's worth remembering that declaring container-type creates a new containment context on the element, which can interfere with layout behaviors that depended on normal flow inside it, so it's best applied to the right wrapper, not to just any ancestor.

Vertical rhythm: trimming the half-leading

line-height includes space above cap height and below the baseline, the so-called half-leading, which becomes obvious when text needs to align flush with another element. text-box-trim: trim-both removes that extra space, and text-box-edge: cap alphabetic measures from the top of capital letters to the alphabetic baseline, replacing the negative margin adjustments made font by font.

css
:is(h1, h2, h3, h4, h5, h6), p, blockquote, figcaption, li {
  text-box-trim: trim-both;
  text-box-edge: cap alphabetic;
}

@supports selector(:heading) {
  :heading {
    text-box: trim-both cap alphabetic;
  }
}

The :heading selector, which would match h1 through h6 and [role="heading"], is a Selectors Level 5 proposal and doesn't yet exist in any browser, which is why it sits behind an @supports selector() check.

Balanced blocks, no orphans, and no reshuffling while someone types

The reset uses three text-wrap values for three different problems, and mixing the three in the wrong place produces a bad effect:

ValueEffectBest use
balanceDistributes text evenly across the block's linesHeadings and short blocks, up to about 6 lines
prettyOnly avoids a stray word on the last line, without balancing the whole blockParagraphs and lists
stablePrevents text from rearranging with every new wordcontenteditable fields

All three replace orphan-correction scripts that ran on the client to measure and reposition line breaks. In an unsupported browser, the declaration is ignored and the text falls back to default behavior, without breaking the layout.

Hyphenation under control, without a hyphen ladder

hyphens: auto depends on a per-language dictionary, so the lang attribute needs to be correct in the HTML or CSS for it to work. The problem is that the default behavior can produce tiny fragments or several hyphenated lines in a row, and the reset tackles this with a set of newer properties:

css
p {
  hyphens: auto;
  lang: en;
  hyphenate-limit-chars: 6 3 3;
  hyphenate-character: "‐";
  hyphenate-limit-lines: 2;
  hyphenate-limit-zone: 8%;
  hyphenate-limit-last: always;

  -webkit-hyphens: auto;
  -webkit-hyphenate-limit-before: 4;
  -webkit-hyphenate-limit-after: 3;
  -webkit-hyphenate-limit-lines: 2;
}

pre, code, var, samp, kbd, :is(h1, h2, h3, h4, h5, h6) {
  hyphens: manual;
  -webkit-hyphens: manual;
}

hyphenate-limit-chars controls the minimum size of a breakable word and the number of characters before and after the hyphen; hyphenate-limit-lines prevents the stacked-hyphen ladder effect; hyphenate-limit-zone reduces the overall frequency of breaks; hyphenate-limit-last protects the paragraph's last word. Safari still relies on the prefixed equivalents, which is why both sets appear together. Code, variables, and headings get hyphens: manual: a broken code word can change meaning, and a hyphenated heading usually looks like a layout mistake.

Hanging punctuation and underlines that respect the ink

Punctuation marks don't carry the same visual weight as letters, so a quotation mark at the start of a line looks slightly indented relative to the text below. hanging-punctuation: first allow-end last lets the specified punctuation hang fractionally outside the text block's edge, so the margins read as truly aligned.

Underlines have a similar problem: the browser applies a fixed thickness and position, regardless of the font's actual weight. text-decoration-thickness: from-font reads the thickness the type designer intended, text-underline-offset gives descenders room to breathe, and text-decoration-skip-ink: auto breaks the line where a descender crosses it, instead of drawing over the letter.

Variable fonts: optical size and weight correction in dark mode

Variable fonts often include an optical size axis, opsz, which lets the font use thinner strokes at large display sizes and thicker, more legible strokes at small sizes. font-optical-sizing: auto delegates that choice to the size actually rendered, with an @supports (font-variation-settings: normal) check covering the older syntax some browsers still require.

In short: light text on a dark background reads heavier than the same weight on a light background, and fonts with a GRAD (grade) axis let you compensate for that perception without changing the glyphs' shape or width, the way a real weight change would. The reset swaps a --vf-grad variable inside @media (prefers-color-scheme: dark), and fonts without that axis simply ignore the declaration.

A less obvious detail shows up when mixing a monospace font into body text: the x-height tends to clash even with the same font-size declared. size-adjust, set once inside @font-face, rescales that specific font's rendered output, without touching the font-size value used elsewhere in the CSS.

Tabular numbers, consistent emoji, and language-specific quotes

Numbers in a table need the same width to form a clean column; numbers in the middle of a paragraph don't need that and should match the surrounding text. font-variant-numeric: tabular-nums lining-nums solves the first case; oldstyle-nums proportional-nums solves the second, keeping the digits at lowercase height.

font-variant-emoji: emoji or text makes the choice between colorful and monochrome emoji explicit: without this declaration, the browser or platform decides, and the result varies more than seems reasonable. The reset's recommendation is to apply it to specific containers, not globally on body.

The quotes property declares the quote order per :lang(), and the native q element inserts the right pair via open-quote and close-quote, without needing to hardcode a character in the content. Wrapping these rules in :where() keeps specificity at zero, so a project can override the quote style with a simple class. Synthesized sub and superscript (where the browser just shrinks the text and shifts the baseline) get an alternative with font-variant-position, which uses glyphs designed for that position when the font has them, though most typefaces only include numerals in that variant, not the full alphabet.

:is() and :where(): the difference that matters in a reset

:where() always resolves to zero specificity, which is useful precisely because every rule in the reset is an opinionated default, and any project needs to be able to override it with a plain class:

css
:where(h1, h2, h3, h4, h5, h6) {
  margin-block: 0.5em;
}

:is() solves a different problem, repetition: the list of heading selectors shows up several times in a typographic stylesheet, and grouping them avoids missing a level. The practical difference is that :is() carries the specificity of the most specific argument in the list, while :where() carries none.

Where this pays off, and where it doesn't

Much of this CSS takes over work that used to leak into JavaScript: text-wrap handles orphan correction, container query units keep a component's scale tied to its own width, and text-box-trim avoids the negative margins previously used to compensate for line height. quotes, font-variant-position, and font-optical-sizing hand smaller details back to the language and the font, instead of a collection of one-off fixes.

Browser support will keep changing, especially for text-wrap and text-box-trim. Unsupported declarations are ignored, but that only helps when the typography without the reinforcement is already acceptable on its own. A news article, a marketing page, and an editable product interface don't ask the same things of text: the set that fits your project depends on the chosen font, the type of content, and the browsers you still need to support. It's worth reading the full tidy-typography.css code in the original CSS-Tricks article before deciding what goes into your stylesheet.

Translated from the Brazilian Portuguese original · Read the original