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.
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.
| Preset | Body | Without the sidecar | Headings | Code and figures |
|---|---|---|---|---|
inter | --family-inter, the default | --stack-sans | Geologica | mono |
geologica | --family-geologica | --stack-sans | Geologica | mono |
plex | --family-plex | --stack-sans | Plex | mono |
literata | --family-literata | --stack-serif | Literata | mono |
plex-mono | --family-plex-mono | --stack-mono | Plex Mono | mono |
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.
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"
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.
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.
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.
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.
| Token | Definition | Specimen |
|---|---|---|
--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.
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.
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.
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.
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.
| Token | Value | Use |
|---|---|---|
--leading-tight | | Display headings only. Do not put running text on it. |
--leading-snug | | Sub-headings and short blocks that are read in one glance. |
--leading-normal | | Body default. Deliberately generous (see below). |
--leading-loose | | Long-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.
| Token | Value | Specimen |
|---|---|---|
--weight-regular | | Account balance |
--weight-medium | | Account balance |
--weight-semibold | | Account balance |
--weight-bold | | Account balance |
Tracking
| Token | Value | Specimen | Use |
|---|---|---|---|
--tracking-tight | | Create a new order | h1 and h2 only. Large type needs the correction; small type does not. |
--tracking-normal | | Create a new order | Everything else. An explicit token so a component can reset back to it. |
--tracking-wide | | Create a new order | Small 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.
| Without | With |
|---|---|
| 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.
| Token | Value | Why |
|---|---|---|
--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.
| Element | Size | Weight | Leading | Tracking |
|---|---|---|---|---|
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 |
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.