Typography
ARC UI ships no font files. It defines five typographic roles and names a reference face for each; you assign your own by overriding one token per role.
Roles, Not Typefaces
A design system that bundles its own typefaces makes two decisions on your behalf: which faces you use, and how they reach the browser. ARC UI makes neither. What it commits to is the roles: which parts of the interface are set in which voice, at what size, weight and tracking. Those relationships are the typographic design; the specific faces are yours.
Every role is four tokens: the family you assign, the fallback stack behind it, the weight the role is set at, and the composed value components actually consume:
--font-body-family: 'Host Grotesk'; /* assign this */
--font-body-fallback: system-ui, sans-serif; /* rarely */
--font-body-weight: 500; /* if your face disagrees */
--font-body: var(--font-body-family), var(--font-body-fallback);
Override only -family and everything downstream (the fallback, and every component using that role) follows. Nothing here needs you to retype a font stack.
The weight is a separate knob because a face you assign will not always ship the weight the role is set at. The Label role is set at 600 across some fifty components; point it at a face whose heaviest cut is 500 and the browser synthesises the difference, which looks like smeared type rather than a missing weight. Setting--font-label-weight once fixes all of them; derived tokens like--section-title-weight and --ui-accent-weight reference the role rather than restating a number.
The Roles
--font-body-familyProse, inputs, descriptions, option lists, table cells. The default for everything not covered by another role.
--font-label-familyForm labels, table headers, eyebrows, dividers, segment labels. Rendered small, uppercase and tracked, so it wants a face that holds up at 12px in caps.
--font-mono-familyCode blocks, keyboard hints, command palette shortcuts, and tabular numerics: meter readouts, stat deltas, calendar day numbers.
--font-display-familyLarge headings and hero type. Inherits the Text role until you assign it a face of its own, so a display typeface is opt-in rather than assumed. The weight does not follow Text; large type usually wants its own.
--font-quote-familyThe quotation glyph on arc-blockquote, the one place the system wants a serif.
--font-accent is kept as an alias of the Label role. It named that role before the slots were split out, and the name was misleading: it reads like a display face, but it has always driven form labels and table headers. Prefer--font-label in new work.
Assigning a Typeface
Set the family tokens on :root, after ARC UI's stylesheet. That is all. Components read the roles through the shadow boundary.
@import '@arclux/arc-ui/base.css';
:root {
--font-body-family: 'Inter';
--font-label-family: 'Inter';
--font-mono-family: 'IBM Plex Mono';
--font-display-family: 'Fraunces'; /* opt into a display face */
}
Assigning one role does not disturb the others. Leaving a role unassigned keeps its reference face, and if that face never loads, its fallback.
Using one typeface everywhere
Point the roles at the same family. The size, weight and tracking distinctions that separate them are defined independently, so the hierarchy survives.
:root {
--font-body-family: 'Inter';
--font-label-family: 'Inter';
}
Scoping to part of a page
Role tokens are ordinary custom properties, so they cascade. Assign them on any ancestor to rebrand a subtree.
.marketing-section {
--font-display-family: 'Playfair Display';
}
Loading the Font Files
Assigning a role tells components which family to ask for. Getting that family into the browser is separate, and ARC UI stays out of it. Self-host, use a font CDN, or use a package like Fontsource. A self-hosted @font-face looks like this:
@font-face {
font-family: 'Inter';
src: url('/fonts/inter-variable.woff2') format('woff2');
font-weight: 100 900; /* the font's real axis range, not one face per weight */
font-display: swap;
font-style: normal;
}
:root { --font-body-family: 'Inter'; }
Declare a variable font once, with a weight range. Declaring the same file under several discrete weights is the most common way to get this wrong: a@font-face naming a single weight gives the browser no way to set thewght axis, so it renders the font's default instance for every one of them. Nothing errors, every weight resolves, and the hierarchy is flat: components in this library ask for weights from 200 to 800 and would all render identically.
Use the range the font actually has. Asking for a weight outside it gets the nearest end of the axis, so a face whose lightest cut is 300 cannot deliver the 200 some roles default to. Set --font-quote-weight and friends to something it can reach instead.
Preload the faces used above the fold, and only those:
<link rel="preload" href="/fonts/inter-variable.woff2" as="font" type="font/woff2" crossorigin />
Use the family name the package registers, not the one you expect
Whatever loads your font decides what the family is called, and the assignment has to use that exact string. This mistake fails quietly: an unknown family name is not an error; it is skipped, so the role falls through to its fallback and the page renders in system-ui, which looks close enough to a grotesque to read as "the font loaded" at a glance. Fontsource's variable packages are where this usually happens; they register the family with a Variable suffix:
import '@fontsource-variable/host-grotesk'; // registers "Host Grotesk Variable"
:root {
--font-body-family: 'Host Grotesk'; /* ✗ silently unused */
--font-body-family: 'Host Grotesk Variable'; /* ✓ what the package registered */
}
The static Fontsource packages (@fontsource/host-grotesk) register the bare name, so the two differ by more than the import path. To check which you have, look at the font-family in the package's own CSS, or confirm in DevTools: the Computed panel names the face actually rendering, and Rendered Fonts names the file it came from. If it says system-ui, the name is wrong.
Fallbacks & Layout Shift
Each role carries a fallback stack so an unassigned or unloaded face degrades to something deliberate rather than to the browser default. You can replace a fallback on its own:
:root { --font-body-fallback: Georgia, serif; }
Fallbacks differ from webfonts in width and vertical metrics, so text reflows when the real face arrives. The fix is a metric-matched fallback declared against your font's actual metrics:
@font-face {
font-family: 'Inter Fallback';
src: local('Arial');
size-adjust: 107%; /* measured against Inter, not a general-purpose value */
ascent-override: 90%;
descent-override: 22%;
}
:root {
--font-body-family: 'Inter';
--font-body-fallback: 'Inter Fallback', system-ui, sans-serif;
}
Tools such as Fontaine and next/font generate these overrides automatically. Pair with font-display: swap; with a metric-matched fallback the swap stops being visible as a jump.
The Reference Faces
The defaults name Host Grotesk, Tomorrow, JetBrains Mono and Georgia. The specimens above render them live (this documentation site loads them, and the components were designed against them), but ARC UI does not ship or load them. If you load nothing, each role renders its fallback, which is a supported configuration rather than a broken one.
Treat them as a reference rendering, not a requirement. The roles are the contract.