Skip to content
@erikakers/typography

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.

scss
@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:

scss
@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.

scss
@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.

scss
@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).

scss
// 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>.

html
<!-- 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.

scss
.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.

scss
.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.

html
<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-face declaration with size-adjust, ascent-override, descent-override, and line-gap-override to target the font-swap jump directly.
  • text-box-trim: Trims the invisible half-leading using text-box-trim and text-box-edge, placing it inside an @supports block since support is recent.
  • x-height-adjust: Sets font-size-adjust to normalize the visual size of fallback fonts, but must not be used on scripts that lack an x-height (like Devanagari).