@arag 0.1.0

Typography tokens

Five selectable typefaces over three system fallback stacks, an eight-step fluid scale whose slopes are calculated rather than guessed, four line heights that leave room for translated text, and the small set of weight, tracking and numeral controls that everything else is built from.

Font stacks

Typography is in three tiers. The three --stack-* tokens are the system fallbacks, one per voice. The five --family-* tokens are the selectable typefaces, each one webfont hook in front of a --stack-*. The --font-* tokens are the roles that base.css, the components and the utilities actually read. Picking a typeface moves one role; see Choosing the typeface.

The shipped path is still all system stacks, and that is a decision rather than an omission: linking src/index.css alone means zero network requests, zero flash of unstyled text, no font files to self-host and no licence to track. The five typefaces only become distinct once you also link the opt-in sidecar; without it they resolve to their --stack-* and nothing can fail to load. The leading faces in every stack cover Latin, Latin Extended and Cyrillic, including the Cyrillic Extended letters (U+04E9 and U+04AF, for example) that most Latin-first stacks fall back to a different face for, producing a visible mismatch mid-word.

The trade-off

This applies to the shipped path, where you link src/index.css and nothing else. A system stack renders differently on Windows, Android and iOS: the same line is Segoe UI on a desktop, Roboto on a Pixel and San Francisco on an iPad, and their metrics do not match. For internal tooling and the kiosk app that is a fair price for the load behaviour. For anything print-adjacent (anything where a fixed line count or a fixed page break matters), check it on all three before signing off, because the wrap points will differ, or link the sidecar and get one face everywhere.

Sans

The body voice: copy, controls, nav, and everything a typeface preset moves. Not headings: those read --font-display, which is Geologica on the default.

Create a new order

Payment details were updated successfully.

Serif

The reading voice for long-form material, and, as --font-serif, a role no preset ever re-points, for the case where one pull quote wants a serif on a page that is not.

Create a new order

Payment details were updated successfully.

Mono

Code, token names, identifiers, and anything that has to align by column.

Create a new order

Payment details were updated successfully.

Display

Large headings. base.css points h1–h6 at it, and it is Geologica over an Inter body: that pairing is the default. Every typeface preset moves this role alongside the body role, so picking a typeface changes headings and copy together; inter is the one preset that keeps Geologica here, because it is the default restated. To make headings track the body face on the default too, set --font-display: var(--font-sans).

Create a new order

Payment details were updated successfully.

Choosing the typeface

One attribute picks the reading voice, choosing from five presets:

<html lang="en" data-font="literata">

The five are inter, geologica, plex, literata and plex-mono, named after the faces, not after roles, because three of them are sans and sans could not say which. inter is the shipped default, and writing it is a true no-op: data-font="inter" and omitting the attribute entirely compute identically, because the preset restates what :root already holds. Same contract as data-brand="blue".

Each preset re-points the body role --font-sans and the display role --font-display together, so a preset is a whole voice rather than half of one. inter is the exception, and only because it is the default restated: it keeps Geologica on headings. What no preset touches is --font-mono: code, a token name and a money figure are monospaced because they need the column to line up, not as a stylistic choice, and a serif page still wants them monospaced. --font-serif is never re-pointed either, for the mirror reason.

PresetBodyWithout the sidecarHeadingsCode and figures
inter--family-inter, the default--stack-sansGeologicamono
geologica--family-geologica--stack-sansGeologicamono
plex--family-plex--stack-sansPlexmono
literata--family-literata--stack-serifLiteratamono
plex-mono--family-plex-mono--stack-monoPlex Monomono

Mono as a reading voice is the one that looks like a mistake and is not. Under that preset body copy and a <code> inside it resolve to the same stack, so the page reads as one technical document rather than as prose with insertions.

Why the third voice is mono and not a second sans

Glyph coverage. Two system sans stacks collapse to the same face on Windows and Android, so offering both would be a choice that does not exist. A display or script face is precisely where Cyrillic coverage thins out first, and coverage, not taste, is the binding constraint here. Mono is a genuinely different voice with the Cyrillic Extended letters (U+04E9 and U+04AF, for example) intact in every face named.

It works on any subtree

Unlike data-brand, this one scopes. Put it on a region and the region changes:

Create a new order

Create a new order from a saved customer basket: data-font="literata"

Create a new order

Create a new order from a saved customer basket: data-font="geologica"

Create a new order

Create a new order from a saved customer basket: data-font="plex-mono"

It scopes because of one extra rule, not for free

Re-pointing the roles is necessary and not sufficient, and the obvious version of this feature was wrong for exactly that reason. font-family inherits as a value: a subtree that declares no font-family of its own has already inherited body's, and never reads --font-sans again. With the presets alone, <aside data-font="literata"> measured as the sans stack throughout.

What closes the gap is one rule in base.css: [data-font] { font-family: var(--font-sans) }, which makes the element carrying the attribute read the role. One declaration covers all five presets, and any added later, because it reads the role rather than naming a family. Delete it and these presets silently become root-only.

If you need identical rendering everywhere

A system stack picks a voice, not a face: the same page is Segoe UI on Windows and San Francisco on macOS. Where that is not good enough, the package ships an opt-in sidecar, src/fonts.css, which is a second link tag and nothing else:

<link rel="stylesheet" href="node_modules/@arag/css/src/index.css">
<link rel="stylesheet" href="node_modules/@arag/css/src/fonts.css">

Link order does not matter. The sidecar declares the @font-face faces and sets one hook property per face: --webfont-inter, --webfont-geologica, --webfont-plex, --webfont-literata, --webfont-plex-mono, which tokens.css reads at the head of each typeface as var(--webfont-inter, ). Setting a hook prepends the face and leaves the --stack-* fallback behind it; unset, the empty fallback substitutes to nothing and the typeface is its stack. That is why the sidecar needs no copy of the stacks, and a second copy of a stack is a copy that drifts.

Three of the five presets need the sidecar to differ

inter, geologica and plex all fall back to --stack-sans. Without the sidecar linked they compute identically: the choice is real only once the faces load. It is never broken, because it degrades to the system sans the package is designed around, but the no-network path cannot honour it. These docs link the sidecar for exactly this reason; the picker above would otherwise have three buttons that do the same thing.

The five faces are Inter, Geologica, Literata, IBM Plex Sans and IBM Plex Mono. All five are SIL OFL 1.1, and all five carry Cyrillic and Cyrillic Extended: the gate a face has to pass to be on this list at all, because letters such as U+04E9 and U+04AF live in the extended range and a face that stops at cyrillic renders them as boxes mid-word. They are declared under renamed families ("Arag Inter" and friends) because a file served under the real name collides with a copy the platform may already have installed, and that collision resolves differently per platform, which defeats the point of using webfonts at all.

Two costs, stated rather than buried. This set is five faces from four foundries, where the previous set was one Noto superfamily: a superfamily guarantees the presets cannot disagree about how Cyrillic looks, and four foundries do not, so check a Cyrillic paragraph in each preset before shipping a page that switches between them. And IBM Plex Mono has no variable cut upstream, so it is declared as four static weights across four subsets: sixteen @font-face blocks, against eight each for Inter, Literata and IBM Plex Sans (four subsets, roman and italic) and four for Geologica, which has no italic upstream. A page still only fetches the weights and ranges it actually renders.

Each family is split by unicode-range, so a page fetches only the ranges it renders: an all-Latin page never downloads the Cyrillic files. Read the ranges before deleting any of them. The cyrillic subset is U+0400-045F plus four strays and does not contain U+04E9 or U+04AF: both live in cyrillic-ext. Drop the cyrillic-ext blocks as "the extended one, probably not needed" and every page that uses those letters falls back to the system face for exactly those two characters, mid-word, which is the defect the stacks were ordered to avoid in the first place.

Linking this file takes on a runtime dependency

The faces load from jsDelivr, pinned to an exact version. Nothing else in @arag has a runtime dependency and the shipped path still has none, but a page that links this file depends on a third party to render, and discloses every viewer's IP address to that CDN. Under consent rules that is a decision for someone other than the person writing the link tag.

The escape is short and is the reason the file is shaped this way. Download the files into src/fonts/ (their names are already in the URLs), rewrite each src: to url("./fonts/<name>.woff2"), and nothing else changes: same family names, same ranges, same hooks, same markup in every consuming page. Ship the OFL text beside them.

One failure mode you will not notice: if the faces do not load, nothing breaks and nothing is reported: each stack simply falls through to the system face it already had. Check the network panel once after wiring it up.

The fluid scale

Eight steps, --text-xs through --text-4xl, each a single clamp(). The slopes are derived rather than eyeballed. For a 16px root, 1vi is V/1600 rem where V is the viewport inline size in pixels. Anchoring the minimum at 20rem (320px) and the maximum at 96rem (1536px) means the vi term has to cover the difference across 0.96 - 0.2 = 0.76:

slope     = (max - min) / 0.76
intercept = min - 0.2 * slope

/* --text-lg: min 1.125rem, max 1.375rem */
slope     = (1.375 - 1.125) / 0.76 = 0.3289
intercept = 1.125 - 0.2 * 0.3289   = 1.0592

--text-lg: clamp(1.125rem, 1.0592rem + 0.3289vi, 1.375rem);

The unit is vi rather than vw, so the scale stays correct if a consumer ever switches writing-mode.

If you retune

An earlier set of slopes topped out around 1230px instead of 1536px. Nothing looked broken: the type simply stopped growing two-thirds of the way to the --size-container-2xl ceiling, and every wide layout read a step too small. The two formulas are a pair: change either the minimum or the maximum and rerun both. Changing one anchor and leaving the slope alone is exactly how that bug happened.

Specimen

One English line per step, set at the step's own size. Leave room for it to grow: the same message routinely runs 15–25% longer once translated, and that is a layout constraint, not a translation problem.

TokenDefinitionSpecimen
--text-xs

Updated 2 minutes ago

--text-sm

User settings

--text-base

Payment details were updated successfully.

--text-lg

Create a new order

--text-xl

Account balance

--text-2xl

Transfer confirmed

--text-3xl

Welcome back

--text-4xl

Total revenue

Line height

Four steps. --leading-normal is deliberately more generous than an English-only system would choose, because other scripts and accented text set taller at the same font size: the x-height can be larger relative to the em, and accents and descenders reach further. A leading that reads as comfortable in English can close up once the text is translated.

--leading-tight is for display headings only. It is safe on two or three words set large; it is not safe on running text, where the descenders start touching the line below.

Each block below is the same paragraph at the full measure, so it wraps to several lines and the difference is visible rather than theoretical.

--leading-tight = : Display headings only. Do not put running text on it.

Accented and non-Latin text sets taller than plain English at the same font size, so a line height that looks comfortable in English can feel cramped once it is translated. Check the descenders against the next line.

--leading-snug = : Sub-headings and short blocks that are read in one glance.

Accented and non-Latin text sets taller than plain English at the same font size, so a line height that looks comfortable in English can feel cramped once it is translated. Check the descenders against the next line.

--leading-normal = : Body default. Deliberately generous (see below).

Accented and non-Latin text sets taller than plain English at the same font size, so a line height that looks comfortable in English can feel cramped once it is translated. Check the descenders against the next line.

--leading-loose = : Long-form reading, and dense tables.

Accented and non-Latin text sets taller than plain English at the same font size, so a line height that looks comfortable in English can feel cramped once it is translated. Check the descenders against the next line.

TokenValueUse
--leading-tightDisplay headings only. Do not put running text on it.
--leading-snugSub-headings and short blocks that are read in one glance.
--leading-normalBody default. Deliberately generous (see below).
--leading-looseLong-form reading, and dense tables.

Weight, tracking and numerals

Weight

Four steps, no more. The --stack-* fallbacks do not all ship every intermediate weight, and asking for one that does not exist gets you a synthesised face that looks wrong in any non-Latin script before it looks wrong in Latin. The sidecar faces do carry all four (every one of their weight axes spans 400 to 700), but the ceiling stays at four steps, because the tokens have to mean the same thing whether or not the faces loaded.

TokenValueSpecimen
--weight-regularAccount balance
--weight-mediumAccount balance
--weight-semiboldAccount balance
--weight-boldAccount balance

Tracking

TokenValueSpecimenUse
--tracking-tightCreate a new orderh1 and h2 only. Large type needs the correction; small type does not.
--tracking-normalCreate a new orderEverything else. An explicit token so a component can reset back to it.
--tracking-wideCreate a new orderSmall uppercase-ish labels and eyebrow text. It widens the string, so leave room for it to wrap.

Tabular numerals

--font-numeric-tabular is a font-feature string, not a length: . tnum makes every digit the same advance width so columns line up; lnum forces lining figures so digits do not sit at different heights beside each other. Currency columns need both.

The same figures, right-aligned. Left column proportional, right column tabular.
WithoutWith
1,240,500 ₮1,240,500 ₮
98,013 ₮98,013 ₮
7,777,777 ₮7,777,777 ₮
111,111 ₮111,111 ₮
640,020 ₮640,020 ₮

src/base.css wires this up for you. Any cell marked data-numeric gets end alignment and both features, so a table author never writes the token by hand:

td[data-numeric],
th[data-numeric] {
  text-align: end;
  font-variant-numeric: tabular-nums;
  font-feature-settings: var(--font-numeric-tabular);
}
<table>
  <thead>
    <tr><th scope="col">Account</th><th scope="col" data-numeric>Balance</th></tr>
  </thead>
  <tbody>
    <tr><th scope="row">5401 2233</th><td data-numeric>1,240,500</td></tr>
  </tbody>
</table>

Underlines

Two tokens, applied by base.css to links.

TokenValueWhy
--text-underline-offset Em-relative, so it tracks the type scale instead of drifting at the large steps. A pixel offset that looks right at --text-sm is a hairline at --text-3xl. It is not zero because descenders reach further down in some scripts: at zero offset the rule cuts through them rather than passing under them.
--text-underline-thickness from-font defers to the face's own metric where one exists, which is what the faces in the stack want. Where none exists the browser falls back to its default, which is correct for that face.

How headings map

src/base.css assigns one scale step per heading level. Nothing else in the system sets a heading size, so this table is the whole mapping.

It also sets the face, which the table below does not repeat because it is the same for every level: h1–h6 read --font-display, not --font-sans. That is what makes headings Geologica over an Inter body, and what a typeface preset moves along with the copy.

ElementSizeWeightLeadingTracking
h1--text-3xl--weight-bold--leading-tight--tracking-tight
h2--text-2xl--weight-semibold--leading-tight--tracking-tight
h3--text-xl--weight-semibold--leading-snug--tracking-normal
h4--text-lg--weight-semibold--leading-snug--tracking-normal
h5--text-base--weight-medium--leading-snug--tracking-normal
h6--text-sm--weight-medium--leading-snug--tracking-normal
Note

h6 is deliberately not uppercased. The usual small-caps or text-transform: uppercase treatment for the smallest heading costs nothing in Latin, but uppercase runs materially wider than lowercase in most scripts: on an element already sitting at --text-sm, that is exactly where a label starts wrapping or clipping. If you want the eyebrow look, reach for --tracking-wide instead and leave the case alone.