Implementation Guide
This guide explains how to apply the typography module to your project, how the pieces fit together, and the reasoning behind them. The public surface is exactly what src/_index.scss forwards. Anything else is internal.
Setting up the foundation
The typography module is built in four layers: tokens, reset, elements, and utilities. You bring these into your project using four mixins, usually in a single global stylesheet.
@use 'pkg:@erikakers/typography' as type;
// 1. WCAG baseline and UA defaults
@include type.reset;
// 2. Custom properties and script overrides
@include type.tokens;
// 3. Tag/class parity
@include type.elements;
// 4. Opt-in utilities
@include type.utilities;The base layer
The reset mixin emits the three mandatory WCAG rules alongside cosmetic user-agent normalizations. If you want the WCAG floor but no cosmetic reset, use conformance instead.
Why two mixins? The WCAG rules (text sizing, reflow, spacing overrides) are normative and cannot be dialed off. The cosmetic rules (margins, lists) are conventions.
Declaring tokens
The tokens mixin emits the --type-* custom properties on :root (or a selector you provide).
The evidence argues against a single global type token because speed, comprehension, and preference diverge across contexts. The module outputs role-scoped properties: --type-size-body, --type-leading-h1, etc. It also emits :lang() script overrides, which outrank the base tokens by specificity rather than source order.
Applying roles
The elements mixin applies the roles to HTML tags and their matching classes. It emits the parity rule: h2, .type-h2 { ... }.
A class exists here for one reason: to let an element take a role’s appearance without taking its place in the document outline. Because tag and class are emitted as one selector list, whatever carries the class receives exactly what a bare tag receives. Specificity settles the rest.
Utilities
The utilities mixin emits .type-measure-*, .type-prose, .type-weight-*, and the case/numeral/style utilities.
Making it yours
The shipped defaults are conventions, not prescriptions. Everything a design system genuinely owns is an input, configured once with with:
@use 'pkg:@erikakers/typography' as type with (
$scale-base: 1.125rem,
$scale-ratio: 1.2,
$font-family-base: (Inter, system-ui, sans-serif),
$font-family-code: ('Fira Code', ui-monospace, monospace),
$leading-base: 1.6,
$script-langs: (en, ja)
);Six inputs drive the whole system. $roles is not one of them — it is an output, derived from the inputs, and you never configure it directly.
| Input | Default | Produces |
|---|---|---|
$scale-base |
1rem |
Every size, as step 0 of the scale |
$scale-ratio |
1.25 |
Every size, as the multiplier between steps |
$font-family-base |
null |
--type-family-* on every role but code |
$font-family-code |
ui-monospace, SFMono-Regular, Menlo, monospace |
--type-family-code |
$leading-base |
1.5 |
Body leading, the leading ceiling, and $rhythm-unit |
$script-langs |
(en) |
Which :lang() blocks exist at all |
Every input is validated at module load, not at the first mixin that happens to read it. $scale-ratio: 1 fails; so does $leading-base: 24px, a misspelled script tag, or an override carrying 14px.
Correcting one role, or one group of them
$role-overrides is the route for the case the derivation gets a single role — or one whole group of them — wrong. It accepts role names and the kind groups (headings, text, all) in the same map, per property.
@use 'pkg:@erikakers/typography' as type with (
$role-overrides: (
'headings': (weight: 'semibold'),
'caption': (size: 0.75rem, leading: 1.4, tracking: 0.01em),
)
);Naming the weights your faces actually have
$font-weights is a map of name to value. Each name arrives as a --type-weight-<name> custom property and a doubled .type-weight-<name> class, so a component can say “semibold” instead of 600 in a place nothing else governs.
@use 'pkg:@erikakers/typography' as type with (
$font-weights: (
'book': 450,
'medium': 550,
'bold': 700,
)
);The other knobs
$root-scale corrects for a host that rescaled the document root. $reset turns off reset()’s cosmetic rules without touching the three WCAG rules it cannot turn off. $elements and $scripts are vocabulary maps you can retarget. The full table of every setting, its default, its tier, and whether it is configurable lives on the Settings page.
The emitted vocabulary
Everything the module emits, in one place: the custom properties a consumer reads, and the classes a consumer writes.
Custom properties
One set per role, plus the named exceptions. A role is one of display, h1-h6, lead, body, caption, fine, code — the Roles page carries the full derivation table.
| Family | Emitted for | Carries |
|---|---|---|
--type-size-<role> |
every role | font-size in rem |
--type-leading-<role> |
every role | unitless line-height ratio |
--type-tracking-<role> |
every role | letter-spacing in em |
--type-measure-<role> |
every role | max-inline-size in ch |
--type-weight-<role> |
every role | font-weight |
--type-lead-<role> |
every role | margin-block-start |
--type-flow-<role> |
every role | margin-block-end |
--type-family-<role> |
where a family is declared | font-family |
--type-wrap-<role> |
where a wrap is declared | text-wrap |
--type-advance-<role> |
code only |
a fixed line advance instead of leading |
--type-weight-<name> |
one per $font-weights entry |
the named weight value |
--type-tracking-uppercase |
once | the case layer’s tracking |
--type-tracking-small-caps |
once | the small-caps tracking |
Read a role’s tokens with var() rather than reaching into the derivation: var(--type-size-h2) says what it means, and it survives a consumer retheming the role behind your back.
Classes
| Class | From | Purpose |
|---|---|---|
.type-<role> |
elements() |
take a role’s appearance without the tag |
.type-measure-<role> |
utilities() |
a role’s measure on any container |
.type-prose |
utilities() |
the prose container: measure and edge trim |
.type-weight-<name> |
utilities() |
apply a named weight |
.type-uppercase, .type-small-caps |
utilities() |
case changes, with tracking compensation |
.type-lowercase, .type-capitalize, .type-case-normal |
utilities() |
case changes, uncompensated |
.type-tabular-nums, .type-oldstyle-nums |
utilities() |
numeral variants |
.type-italic, .type-not-italic |
utilities() |
style |
The case classes that change tracking (.type-uppercase, .type-small-caps) are doubled to (0,2,0) because letter-spacing is a property role() sets; the rest are single classes, safe against the parity group because role() never sets text-transform, font-variant-*, or font-style.
Working with components
The module is built to be consumed by your design system or component library. You can apply its typography system either through Sass mixins or utility classes, depending on your architecture.
Applying roles in Sass
When you build your own components, you shouldn’t rely on raw variable references (like var(--type-size-h3)) for typographic properties. The module provides helpers to fetch them coherently.
To apply a role’s text properties to a custom component, use the role mixin. It emits font-size, line-height, letter-spacing, font-weight, and font-family by var() reference. To apply vertical spacing, use the rhythm mixin, which emits margin-block (split into lead above and flow below).
// src/components/_card.scss
@use 'pkg:@erikakers/typography' as type;
.card {
// ... container styles
}
.card__title {
// Gives this element the exact appearance of an h3, without forcing you
// to use an <h3> tag in the markup if a different heading level (or a div)
// makes more sense for the document outline.
@include type.role('h3');
@include type.rhythm('h3');
}
.card__meta {
// The 'fine' role is the small-text layer under captions (e.g. metadata,
// bylines, table headers).
@include type.role('fine');
color: var(--color-text-subtle);
}Applying roles in markup
If you prefer utility classes or don’t want to write Sass for every element, the elements and utilities mixins emit a set of classes you can use directly in your HTML.
The parity rule guarantees that a class like .type-h2 receives the exact same declaration block as a bare <h2>.
<!-- An h3 in the outline, but rendering at the size and rhythm of an h2 -->
<h3 class="type-h2">A major section</h3>
<!-- A label using the uppercase utility, which automatically opens the
tracking to compensate for the lack of ascenders/descenders -->
<span class="type-fine type-uppercase">Published</span>Protecting interactive elements
When building interactive components (like buttons or form controls), you must ensure the text survives a user’s text-spacing override (WCAG 1.4.12) without clipping.
If you give a button a fixed height (height: 40px) and overflow: hidden, a user applying the WCAG text-spacing overrides (line-height 1.5x, letter-spacing 0.12em) will see their text cut off.
The safe-box mixin provides a safe container shape using min-block-size and em-based padding.
.button {
@include type.role('body');
@include type.safe-box($min-block-size: 2.5em, $padding-inline: 1em);
display: inline-flex;
align-items: center;
justify-content: center;
}Layout and measure
Measure belongs to a container, never to an element. A paragraph inside a card or a sidebar must fill the box it was put in. If you put max-width: 65ch directly on a <p> tag, it will fight every grid and flexbox layout you place it in.
The prose mixin creates a text container that enforces a maximum line length on its children and trims vertical margins at the edges, so the container fits flush against the grid.
.article-body {
// Limits the measure to the 'body' role's derived character count,
// and trims the top margin of the first child and bottom margin of the last.
@include type.prose('body');
}If you don’t want to use the mixin, the module emits a .type-prose utility class that does the exact same thing.
<article class="type-prose">
<p>This paragraph will be constrained to the readable measure.</p>
<p>And the margins at the top and bottom of the article will be trimmed.</p>
</article>The measure function backs this up. It returns a ch length, but it throws a build error if the character count is outside the psychophysical bounds of 13 to 80 characters. The module ships 65 characters as a convention, but it enforces the 80-character WCAG 1.4.8 maximum and the ~13-character measured reading speed floor.
Managing lengths and scales
The scale function calculates rem lengths from a step number, the base size, and the modular ratio.
To ensure your lengths are safe and accessible, the module validates them. The relative function checks that a value is text-relative (like em or rem) and refuses absolute units (px), viewport units (vw), and container units. Viewport units do not scale with browser zoom, which is an accessibility failure, not an aesthetic preference.
The root-relative function rescales a value against the $root-scale if it is a root-relative unit, returning it unchanged otherwise.
Font metrics
Baseline grids solve a wide-multi-column print problem the web largely does not have. CSS has no concept of a baseline. Instead, this module provides tools to address specific metric issues:
fallback-metrics: Generates a@font-facedeclaration withsize-adjust,ascent-override,descent-override, andline-gap-overrideto target the font-swap jump directly.text-box-trim: Trims the invisible half-leading usingtext-box-trimandtext-box-edge, placing it inside an@supportsblock since support is recent.x-height-adjust: Setsfont-size-adjustto normalize the visual size of fallback fonts, but must not be used on scripts that lack an x-height (like Devanagari).