npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@canonical/styles-typography

v0.45.0

Published

Typography baseline alignment engines and scale tokens for the Canonical Design System.

Readme

@canonical/styles-typography

Baseline grid alignment for the Canonical Design System. This package provides three interchangeable CSS engines that snap text baselines to a configurable grid, plus the typography tokens and the element rules that bridge @canonical/design-tokens to the engine's variable contract.

Quick Start

Import the default engine (cap-unit):

@import url("@canonical/styles-typography");

That's it. All h1–h6 and p elements will align to the baseline grid. The default engine uses the CSS cap unit and requires no JavaScript font extraction.

The grid unit

--baseline-height is the size of one grid step, and it is optional. Declare it in whatever length unit suits the page:

:root {
  --baseline-height: 0.5rem;  /* or 8px, or 0.25rem, or 6pt */
}

rem is the usual choice, because the grid then follows the reader's own font size: a reader who sets a larger base size gets a proportionally larger grid, and the type stays on it. px fixes the grid in CSS pixels instead, so it stays the same whatever the reader's font size, which is what you want when the grid has to line up with something else measured the same way — a background image, or a rule drawn by another system.

A CSS pixel is a reference unit, not a device pixel: on a high-density display one CSS pixel covers several physical ones, and the browser's zoom changes how many. px buys you a grid that does not move with the font size; it does not buy alignment with the display's own pixels.

Declare it nowhere and the grid is 0.25rem, four pixels at the usual root font size. The default is declared once, in tokens.css, inside the package's ds.tokens block:

:where(:root) {
  --baseline-height: 0.25rem;
}

:where() puts it at zero weight, so any real declaration of the property beats it whatever the order within the layer: @canonical/styles declares it at :root in spacing.css, an application may declare it on any element, and either is obeyed. elements.css and the three engines then read var(--baseline-height) bare, twenty-six times between them, with no fallback to keep in step.

The one case with no default is an engine linked without tokens.css. No engine imports anything — that is the shape this package is cut to, and the composition lives in index.css — so a stylesheet taking one engine alone either declares --baseline-height itself or imports ./tokens.css beside it; without one of those the nudges resolve to nothing and the engine does not run. The package entry composes both and is unaffected, as is anything importing @canonical/styles.

How this package is cut

Five files, and the cut between them is the point of this section: it is a pattern, not a filing decision, and the same pattern runs through @canonical/styles and the Vanilla adapter.

A file is a concept, named for what it is

Not for what it includes, and never for what it leaves out.

| File | The concept | | --- | --- | | tokens.css | the values. The names that map the design tokens' typographic scale onto what the engines and the element rules read. It declares custom properties and styles nothing. | | elements.css | what bare elements get. body, h1–h6, p, .p, .code, .editorial. | | baseline-cap.css, baseline-metrics.css, baseline-trim.css | the engines. The arithmetic that puts a line on the grid. Interchangeable, one loaded at a time. | | index.css | the whole, and nothing else. It composes the tokens, the element rules and the default engine, in that order. |

Each of the five is an entry point, and the name in the manifest is the name of the concept.

Leaves import no sibling; entries compose

No leaf imports another file of this package. Only index.css does, and only the three it composes.

One leaf imports outside the package: tokens.css pulls in @canonical/design-tokens' typographic scale, the file whose names it shims. That is a dependency rather than a sibling, and it is the only one — the engines and elements.css import nothing at all.

That is not tidiness. A browser treats every @import as its own stylesheet and de-duplicates nothing, so a file reached by two paths is fetched, parsed and applied twice. The first cut of this package had elements.css importing tokens.css while the engines imported the scale as well, and the resolved stylesheet carried the typographic scale twice — 48,270 duplicated bytes, a quarter of the entry. Leaves that import no sibling make one path per file true by construction rather than by vigilance, and the one outside import is reached from tokens.css alone.

What it asks of a consumer is small and worth stating: link a file and you get what that file is, and you declare what it reads. An engine on its own reads --baseline-height and --font-size; the element rules read the values tokens.css declares. The baseline unit may be written in rem or px, and defaults to 0.25rem when nothing declares it.

The same word means the same thing in every package

@canonical/styles is cut the same way and uses the same three nouns. Its tokens.css is the values and takes this package's tokens.css; its elements.css is what bare elements get and takes this package's elements.css and the default engine; its layout.css is the layout presets. Its index.css is the whole.

@canonical/styles-vanilla-adapter ships an elements.css too: the same three element layers, built from the same source files, addressed to an island rather than to the page.

Three contexts, one vocabulary. A reader who has understood elements.css once has understood it everywhere, and the only thing left to ask is which page region it is aimed at.

Why the old names went

mapper.css was jargon — an internal word for the file that named the tokens, which told a reader nothing about what was inside it and hid the fact that the file was doing two jobs.

The styles package's core.css was worse, because it named an inclusion rather than a concept: "everything except the element layers". A name defined by subtraction tells you what a file is not. You cannot tell whether a rule belongs in it without first knowing the whole list it is subtracting from, and when the list changes the name silently stops being true. tokens.css, elements.css and layout.css each name something a rule either is or is not.

How to add a file

Decide which concept it belongs to. That one decision settles the rest:

  • its name — the concept's noun;
  • its layer — ds.tokens for values, ds.typography for element rules and engines;
  • which entries import it — the entry for that concept, and no other.

If it belongs to no concept, that is the finding: either the concept is missing, or the file is two files. This package's exports map is the contract for what a consumer may link.

Cascade layers

Two facts about the CSS cascade shape this package.

A rule in no cascade layer outranks a rule in any layer, whatever the selectors on either side. And within one layer, two rules of equal specificity are settled by which one loaded second.

This package used to ship every one of its rules in no layer at all, so the first fact applied to all of them: nothing an application wrote in a layer could beat them. The second fact bit hardest on the element selectors, body, h1–h6 and p, which sit at specificity (0,0,1): an application's own p rule tied with this one exactly, and the bundler's output order decided the winner, one property at a time. The rest of the file is more specific than that — .p, .code and .editorial are classes, :root is a pseudo-class, and .editorial h1 is a class and an element together — so those won on specificity rather than by luck, which is its own problem when an application meant to override them.

Layered, the design system loses to an application's unlayered CSS, deliberately and predictably, and beats the layers below it, also deliberately.

| What | Layer | | --- | --- | | tokens.css — the design-tokens naming shims (:root, custom properties only) | ds.tokens | | tokens.css — the typographic scale it imports | ds.modifiers, which that file opens itself | | elements.css — the body, h1–h6, p, .p, .code and .editorial rules | ds.typography | | baseline-cap.css, baseline-metrics.css, baseline-trim.css — every rule | ds.typography |

ds.typography sits above ds.reset and below ds.modifiers in the order @canonical/styles declares, so the typographic scale in ds.modifiers can retune what the engine produces, and a component stylesheet — higher still — is always the final word on its own text.

The naming shims are in ds.tokens and not ds.typography because they are custom properties and nothing else: a custom property does nothing where it is declared, only where a rule reads it, so they belong beside the other primitive values.

These rules select elements by name — body, h1, p — so they apply to the whole document. That is what a design system's typography is for.

This package states no layer order of its own: it is imported by @canonical/styles after that package's order statement, which is the first rule of the first stylesheet and names all three layers this package writes to. Linked on its own, as the example does, the layers are created where they first appear, which is well defined for a single package and settles nothing this package needs settled — no custom property is declared in more than one of the layers involved.

This section is the reference for this package's share of the arrangement. The reasoning behind the whole of it — how a browser decides, why a rule in no layer beats every layered one, and where the confinement for a page running a second CSS framework lives — is the cascade contract.

How It Works

The browser adds invisible half-leading above and below each line of text. The exact amount depends on the font's internal metrics, the computed font-size, and line-height. This makes vertical alignment between different text elements unpredictable.

The baseline engines solve this by computing where the first baseline falls within a line box, then applying a padding-top / margin-bottom pair that nudges the element so its baseline lands exactly on a grid line. The complementary margin-bottom ensures the element's total outer height remains a multiple of --baseline-height.

 line-height (computed)
 +----------------------------------------------+
 |  half-leading (top)                           |
 |  +----------------------------------------+  |
 |  | ascender area                           |  |
 |  | - - - - cap line - - - - - - - - - - - |  |
 |  | glyph body                              |  |
 |  | - - - - baseline - - - - - - - - - - - |  |  ← snap this to the grid
 |  | descender area                          |  |
 |  +----------------------------------------+  |
 |  half-leading (bottom)                        |
 +----------------------------------------------+

The mod() CSS function does the heavy lifting:

--top-nudge: calc(
  var(--baseline-height) -
  mod(var(--baseline-position), var(--baseline-height))
);

Multi-line blocks stay on-grid because line-height is always set to a multiple of --baseline-height. The nudge only compensates for the first line's half-leading offset.

Engines

Three engines implement the same grid-snap pattern with different trade-offs. Import the one that fits your constraints directly, or use index.css which re-exports the default (cap-unit).

baseline-cap.css — Cap unit (default)

@import url("@canonical/styles-typography/src/baseline-cap.css");

Uses the browser-native cap CSS unit to resolve font metrics at render time. No JavaScript extraction step, no per-font variables. Changing font-family on an element automatically updates the 1cap value the engine uses.

The baseline position formula is (line-height + 1cap) / 2 — the browser resolves 1cap from the font's OpenType tables natively.

| Browser | mod() | cap unit | This engine's floor | |---------|----------|------------|---------------------| | Chrome | 125+ | 118+ | 125+ | | Safari | 15.4+ | 17.2+ | 17.2+ | | Firefox | 118+ | 97+ | 118+ |

mod() binds Chrome and Firefox, the cap unit binds Safari.

baseline-metrics.css — Extracted metrics

@import url("@canonical/styles-typography/src/baseline-metrics.css");

The original engine with the widest browser support. Requires three CSS variables extracted from the font file using the extract-font-data CLI:

:root {
  --ascender: 1068;
  --descender: -292;
  --units-per-em: 1000;
}

The baseline position is computed from these metrics: ((line-height - line-height-scale) / 2) + ascender-scale. More verbose, but works everywhere mod() is supported.

baseline-trim.css — Text-box-trim hybrid

@import url("@canonical/styles-typography/src/baseline-trim.css");

The most modern approach. Uses text-box: trim-both cap alphabetic to remove half-leading entirely, then compensates with mod()-based margin to restore grid alignment. Results in tighter content boxes (useful for buttons, cards, optical centering).

| Browser | text-box-trim | mod() | cap unit | This engine's floor | |---------|-----------------|----------|------------|---------------------| | Chrome | 133+ | 125+ | 118+ | 133+ | | Safari | 18.2+ | 15.4+ | 17.2+ | 18.2+ | | Firefox | 154+ | 118+ | 97+ | 154+ |

text-box-trim binds every column. The cap unit is in the list because the nudge measures the cap height itself, mod(calc(-1 * 1cap), …), so this engine needs it as much as the cap engine does.

It does not fall back to the grid. Below the floor the trim is skipped and the element gets its half-leading back, but the nudge that survives was computed for a trimmed box and never reads the line height, so the text lands off the grid by a fraction of a unit — measured, 6.516px on a 16px serif at a 24px line, whatever the unit is. Use the cap engine for those browsers.

Consumer Contract

Every engine reads the same set of CSS custom properties per element:

| Variable | Scope | Description | |----------|-------|-------------| | --baseline-height | :root | Grid unit size, in any length unit (e.g. 0.5rem or 8px) — optional wherever tokens.css is loaded, which declares the 0.25rem default; required by an engine linked without it | | --font-size | element | Font size as a <length> | | --line-height-multiplier | element | Line height in baseline-height units | | --line-height | element | Optional override: explicit line height, bypasses the multiplier | | --space-after | element | Optional: extra bottom margin in baseline-height units |

The metrics engine additionally requires on :root:

| Variable | Description | |----------|-------------| | --ascender | Font ascender value (unitless, from OpenType tables) | | --descender | Font descender value (unitless, negative) | | --units-per-em | Font units-per-em value |

Tokens and elements

tokens.css and elements.css together bridge the semantic typography tokens from @canonical/design-tokens to the engine's variable contract. Both are imported by index.css, the package's composed entry point.

The design tokens provide variables like:

--typography-heading-1-font-size
--typography-heading-1-line-height    (unitless ratio)
--typography-heading-1-font-weight
--typography-heading-1-letter-spacing
--typography-heading-1-font-family

elements.css converts these into the engine variables for each element (h1–h6, p, .p, .code). It sets --line-height as a length, never a multiplier: it prefers the exact dimension the design tokens carry, and falls back to the tier's ratio snapped up onto the grid.

--line-height: var(
  --typography-heading-1-line-height-dimension,
  round(
    up,
    calc(
      var(--typography-heading-1-font-size) *
      var(--typography-heading-1-line-height)
    ),
    var(--baseline-height, 0.25rem)
  )
);

--line-height-multiplier is the other half of the engines' contract, for a consumer who drives an engine directly rather than through this mapping: an engine reads --line-height if it is set, and calc(--baseline-height * --line-height-multiplier) if it is not. The example uses the multiplier; elements.css uses the length.

Package Structure

src/
  index.css              ← the package composed (tokens, elements, cap engine)
  tokens.css             ← the typographic scale and the naming shims
  elements.css           ← the rules that read them
  baseline-cap.css       ← cap-unit engine
  baseline-metrics.css   ← extracted-metrics engine
  baseline-trim.css      ← text-box-trim + cap hybrid
  scripts/
    extractFontData.ts   ← CLI for extracting font metrics
example/
  index.html             ← interactive demo with engine switcher
  serve.ts               ← dev server with live-reload
  scripts/               ← sidebar, font picker, content presets
  fonts/                 ← bundled .woff2 files for the demo
  styles/                ← demo layout and debug styles

Extracting Font Metrics

The extract-font-data CLI reads OpenType metrics from a font file. This is only needed when using the baseline-metrics engine.

bun run extract-font-data ./path/to/font.ttf

Output:

Add the following variables to your CSS and follow the instructions in the README:
:root {
  --ascender: 1068;
  --descender: -292;
  --units-per-em: 1000;
}

Pass --all to see the full metrics table including computed nudge values for a given line-height:

bun run extract-font-data ./path/to/font.ttf 1.5 --all

Caveats

The extractor uses opentype.js to parse font files. It works with most .ttf, .otf, and .woff files but may fail on fonts without TrueType or CFF outlines:

error: Font doesn't contain TrueType or CFF outlines.

Development

Start the interactive example with live-reload:

bun run dev

This serves the example at http://localhost:3333/example/. The sidebar lets you:

  • Switch engines — toggle between cap-unit, metrics, and text-trim in real time
  • Switch fonts — pick from bundled fonts with auto-applied metrics
  • Tune per-tag — adjust font-size, line-height multiplier, and space-after for each heading level and paragraph
  • Adjust baseline — change the baseline grid height and see the alignment update

The baseline grid is rendered as a red 1px line overlay so alignment errors are immediately visible.

Browser Support

All engines require mod() for the grid-snap calculation:

| Feature | Used by | Chrome | Safari | Firefox | |---------|---------|--------|--------|---------| | mod() | all three engines | 125 | 15.4 | 118 | | round() | the line-height fallback in elements.css | 125 | 15.4 | 118 | | cap unit | the cap and text-trim engines | 118 | 17.2 | 97 | | text-box-trim | the text-trim engine only | 133 | 18.2 | 154 |

Read the table by engine, not row by row — an engine's floor is the highest number in its column among the features it uses:

| Engine | Chrome | Safari | Firefox | What binds | |--------|--------|--------|---------|------------| | baseline-cap.css | 125 | 17.2 | 118 | mod(), and the cap unit in Safari | | baseline-metrics.css | 125 | 15.4 | 118 | mod() throughout | | baseline-trim.css | 133 | 18.2 | 154 | text-box-trim throughout |

One caveat on that table, and it is a real one.

Below text-box-trim, the text-trim engine does not hold the grid. The trim is skipped, the element gets its half-leading back, and the nudge that survives — mod(calc(-1 * 1cap), unit) — was computed for a trimmed box and never reads the line height, so it cannot compensate. Measured in Chromium with a 16px serif on a 24px line: with the trim applied the first baseline sits at 12px on a 4px grid, 16px on an 8px grid and 12px on a 12px grid, every one a whole number of units; with the trim ignored it moves 6.516px in each case, which is 1.63, 0.81 and 0.54 units. The element's outer height stays a whole number of units, so blocks still stack on the grid, but the text inside them does not sit on it. A browser below the floor should use the cap engine, whose nudge is computed from the untrimmed line box.

mod() is the hard floor. Below it no engine computes a nudge at all and text falls back to its natural leading.