Typography
Choose a base and a ratio once, then follow them everywhere: semantic text utilities, font pairing, and a generator that emits whatever you picked.
text-eyebrowFoundations
text-h1Design that scales
text-leadOne ratio, chosen once and applied everywhere.
text-pBody copy sits at the base size. Every heading above it is that base multiplied by the ratio, and every step below it is the base divided by the same number — so no size on this page was placed by eye.
text-smallHelper text sits one step under the base.
Purpose
Typography is one fluid modular scale exposed as a semantic utility per role. You write
text-h1, text-lead, text-small — never text-2xl font-bold tracking-tight. Each class
carries size, line height, letter spacing, weight and font family, so a single class is enough.
The system is opinionated about having a rule, not about which rule. Which base, which ratio, how many levels, one font or two — all yours. What it will not give you is a way to place a size that is not on the scale you chose. Pick the rule at the start of a project, generate from it, and spend no further decisions on size.
A modular scale is a constraint, not a decoration. Its value is that it removes the question — not that the numbers are beautiful.
Everything on this page — the CSS in global.css, the ramp below, the generator — comes from
src/lib/type-scale.mjs. There is no second copy to drift. The numbers rendered here are
whatever this site’s config currently holds; change it in the theme builder and the
page changes with it.
Classes, not tags
Style .text-h1. Never style a bare h1.
A heading’s tag is document structure; its class is appearance. SEO and accessibility regularly force them apart:
<!-- The page already has an h1; this is a section heading that must look like one -->
<h2 class="text-h1">Pricing</h2>
<!-- Visually a heading, but not part of the outline -->
<div class="text-h1">404</div>
<!-- A real h3 that should read at h5 size -->
<h3 class="text-h5">Related articles</h3>If the scale were attached to h1–h6 element selectors, none of these would be possible without
fighting the cascade. The only element rule the system sets is body, which picks up
text-p so unclassed copy is already correct.
The scale
The role list is fixed; the numbers behind it are yours. text-h1 is always the largest step,
text-p is always body copy, text-small is always beneath it — whatever base and ratio you feed
the generator. That split is what makes markup portable across themes: a component names a role,
never a size.
This site is currently on base 1rem · ratio 1.2 (Minor Third)
on mobile → 1.25 (Major Third) on desktop, interpolated
between 20rem and 80rem. Those numbers
suit a dense documentation site. They are a starting point, not a recommendation — see
choosing a ratio.
Every heading step is a clamp(). Sizes at or below the base are constant — helper text has no
reason to grow on a wide screen.
Headings
text-h12.986rem → 3.815rem (47.8px → 61px) · lh 1.1 · 700Heading level 1Design that scales
text-h22.488rem → 3.052rem (39.8px → 48.8px) · lh 1.15 · 700Heading level 2Design that scales
text-h32.074rem → 2.441rem (33.2px → 39.1px) · lh 1.2 · 600Heading level 3Design that scales
text-h41.728rem → 1.953rem (27.6px → 31.3px) · lh 1.25 · 600Heading level 4Design that scales
text-h51.44rem → 1.563rem (23px → 25px) · lh 1.3 · 600Heading level 5Design that scales
text-h61.2rem → 1.25rem (19.2px → 20px) · lh 1.35 · 600Heading level 6Design that scales
Text roles
text-lead1.147rem → 1.182rem (18.3px → 18.9px) · lh 1.6 · 400Introductory paragraph under a headingThe quick brown fox jumps over the lazy dog.
text-quote1.147rem → 1.182rem (18.3px → 18.9px) · lh 1.7 · 400Pull quotes and blockquotesThe quick brown fox jumps over the lazy dog.
text-p1rem (16px) · lh 1.7 · 400Default body copyThe quick brown fox jumps over the lazy dog.
text-small0.915rem (14.6px) · lh 1.5 · 400Helper text under a control. Never a paragraphThe quick brown fox jumps over the lazy dog.
text-eyebrow1.095rem → 1.118rem (17.5px → 17.9px) · lh 1.4 · 600Kicker above a headlineThe quick brown fox jumps over the lazy dog.
text-code0.946rem (15.1px) · lh 1.5 · 400Inline code, IDs, metricsThe quick brown fox jumps over the lazy dog.
text-control-sm0.757rem (12.1px) · lh 1 · 500Small control labels — dense toolbars, table row actionsThe quick brown fox jumps over the lazy dog.
text-control0.875rem (14px) · lh 1 · 500Control labels — the default button sizeThe quick brown fox jumps over the lazy dog.
text-control-lg1rem (16px) · lh 1 · 500Large control labels — hero calls to actionThe quick brown fox jumps over the lazy dog.
Reference
| Utility | Size | Line height | Tracking | Weight | Use |
|---|---|---|---|---|---|
text-h1 | clamp(2.986rem, 2.71rem + 1.381vw, 3.815rem) | 1.1 | -0.03 | 700 | Heading level 1 |
text-h2 | clamp(2.488rem, 2.301rem + 0.939vw, 3.052rem) | 1.15 | -0.0208 | 700 | Heading level 2 |
text-h3 | clamp(2.074rem, 1.951rem + 0.613vw, 2.441rem) | 1.2 | -0.0133 | 600 | Heading level 3 |
text-h4 | clamp(1.728rem, 1.653rem + 0.375vw, 1.953rem) | 1.25 | -0.0075 | 600 | Heading level 4 |
text-h5 | clamp(1.44rem, 1.399rem + 0.204vw, 1.563rem) | 1.3 | -0.0033 | 600 | Heading level 5 |
text-h6 | clamp(1.2rem, 1.183rem + 0.083vw, 1.25rem) | 1.35 | 0 | 600 | Heading level 6 |
text-lead | clamp(1.147rem, 1.135rem + 0.059vw, 1.182rem) | 1.6 | 0 | 400 | Introductory paragraph under a heading |
text-quote | clamp(1.147rem, 1.135rem + 0.059vw, 1.182rem) | 1.7 | 0 | 400 | Pull quotes and blockquotes |
text-p | 1rem | 1.7 | 0 | 400 | Default body copy |
text-small | 0.915rem | 1.5 | 0 | 400 | Helper text under a control. Never a paragraph |
text-eyebrow | clamp(1.095rem, 1.088rem + 0.038vw, 1.118rem) | 1.4 | 0.08 | 600 | Kicker above a headline |
text-code | 0.946rem | 1.5 | 0 | 400 | Inline code, IDs, metrics |
text-control-sm | 0.757rem | 1 | 0 | 500 | Small control labels — dense toolbars, table row actions |
text-control | 0.875rem | 1 | 0 | 500 | Control labels — the default button size |
text-control-lg | 1rem | 1 | 0 | 500 | Large control labels — hero calls to action |
Choosing a ratio
The ratio is the constant between one step and the next. It is the single decision that sets how loud your hierarchy is, and none of the classical intervals is more correct than another. The point is to pick one and stop deciding.
| Ratio | Interval | text-h3 | text-h1 |
|---|---|---|---|
1.067 | Minor Second | 20.7px | 23.6px |
1.125 | Major Second | 25.6px | 32.4px |
1.2 | Minor Third | 33.2px | 47.8px |
1.25 | Major Third | 39.1px | 61px |
1.333 | Perfect Fourth | 50.5px | 89.8px |
1.414 | Augmented Fourth | 64px | 127.9px |
1.5 | Perfect Fifth | 81px | 182.3px |
1.618 | Golden Ratio | 109.7px | 287.1px |
Six levels off a 1rem base, which is where the choice shows: text-h1 is the base
multiplied by the ratio six times over.
Ratio and level count are one decision
A ratio compounds, so it cannot be chosen without also deciding how many heading levels you need
(--steps, six by default).
Six levels at 1.618 puts text-h1 at 287.1px — a
poster, not a page. The same ratio over three levels lands at 67.8px,
which is a usable hero. A dramatic ratio is not wrong; a dramatic ratio stretched over six levels is.
- 1.067–1.125 — dense product and admin UI. Hierarchy comes from weight, color and space; size barely works at all.
- 1.2–1.25 — the general-purpose range, and what this site runs. Six levels stay usable.
- 1.333–1.5 — marketing and editorial, where an
h1is meant to be an event. Plan on three or four levels rather than six. - 1.618 — display. One heading, one paragraph, one page.
npm run tools:generate-type-scale -- --ratio 1.333 --steps 4Two ratios, one ramp
The generator takes a ratio at each end of the viewport range and interpolates between them, which
is why every heading step is a clamp() and not a breakpoint. Setting the small-viewport ratio
lower compresses the hierarchy on a narrow screen, so an h1 stays readable instead of
overflowing. Pass --ratio instead of the two --ratio-min/--ratio-max flags if you would
rather the ramp keep one shape at every width.
Roles at or below the base use a single ratio at both ends. A larger ratio produces a smaller number below the base, so applying the pair down there would shrink captions as the viewport grows.
Choosing a base size
The base is the number the whole scale is built from — every step is the base multiplied by the ratio some number of times. Change it and the entire ramp moves with it.
Never set a root font size
Do not write html { font-size: 18px }, and do not use the 62.5% trick. The root size is the
reader’s setting. Someone who raised their browser default because they cannot comfortably read
16px has that preference silently cancelled the moment you pin the root — which is precisely the
failure mode WCAG 1.4.4 Resize Text
exists to prevent.
Set it on body, in rem
That is what the generator emits:
@theme {
--type-p-size: 1.125rem; /* 18px for a reader on the default */
}
@layer base {
body {
font-family: var(--font-body);
font-size: var(--type-p-size);
}
}1.125rem renders at 18px for a reader on the 16px default, and at 22.5px for someone browsing
at 20px. That proportionality is the entire point of rem, and it is why px is an input convenience on
this page but never an output — nothing in the generated CSS is in px.
npm run tools:generate-type-scale -- --base 18px # same as --base 1.125remPicking a number
- 16px — dense product and admin UI, data tables, anywhere vertical space is contested.
- 17–18px — content-led marketing and editorial, where reading comfort beats density.
Base and ratio are not independent knobs: raising the base lifts every step, so a larger base with a gentler ratio usually reads better than 16px with an aggressive one. If headings feel oversized after raising the base, lower the ratio rather than capping the steps.
Mobile and desktop
The two base fields are the ends of the fluid range. Leaving them equal — the default — keeps body copy at one size and scales only the headings. Setting mobile 16px and desktop 18px makes body copy itself fluid, and every step scales with it.
Koval Core ships 16px at both ends
(1rem), because a documentation site is dense reference material.
Font roles
Three roles, and you supply one or two families for them.
| Token | Role | Used by |
|---|---|---|
--font-heading | Display and headings | text-h1–text-h6, text-eyebrow |
--font-body | Reading text and control labels | text-lead, text-p, text-small, text-quote, text-control-sm–text-control-lg, and body |
--font-mono | Code and data | text-code |
One font for everything — set --font-heading and --font-body to the same family. This is the
default and the right choice for most product UI.
Two fonts — a display face for headings and a neutral face for body. Reserve this for editorial and marketing surfaces; it costs a second webfont on every page.
Never use more than one family per role, and never set a mono face on headings or body copy.
Installing a font
Webfonts come from Fontsource, which self-hosts them — no third-party
request at runtime, no layout shift from a CDN handshake. Prefer the @fontsource-variable/*
packages: one file covers every weight.
npm i @fontsource-variable/fraunces @fontsource-variable/inter// src/layouts/BaseLayout.astro — import once, in the root layout
import '@fontsource-variable/fraunces';
import '@fontsource-variable/inter';@theme {
--font-heading: "Fraunces Variable", Georgia, serif;
--font-body: "Inter Variable", ui-sans-serif, system-ui, sans-serif;
}The theme builder emits all three of these for whichever pairing you pick.
Generate a scale
Pick a ratio and a pairing in the theme builder — the whole site restyles as
you go, and the ramp you see is the one the exported global.css carries. The same
output is available on the command line:
npm run tools:generate-type-scale -- --ratio-max 1.333 --font inter --proseLong-form content
Markdown, CMS output and anything else you do not control gets .prose. The generator’s second
block puts .prose on the same scale, and works with
@tailwindcss/typography — which is
Tailwind v4 compatible, via @plugin "@tailwindcss/typography";. The plugin wraps its rules in
:where(), so the generated selectors override it without !important.
.prose headings sit 2 steps down the ramp by
default: an article title is not a landing-page hero, so .prose h1 uses the
h3 step. Set the offset to 0 if you want
them to match exactly.
Inline code is sized in em rather than on the scale, so it never outgrows a table cell or
overwhelms a heading it sits inside.
Every rule in that block is element-targeted — .prose :is(p, ul, ol, dl) — which outranks a role
class on the same element. So each one carries the plugin’s not-prose guard: inside an island
marked not-prose, the article stops applying and text-h1 on a <p> is once again an h1.
The guard is :not(:where(…)), which costs no specificity, so the block still beats the plugin.
Editing the scale
The block between the type-scale:start and type-scale:end sentinels in
src/styles/global.css
is generated. Do not edit it by hand — change the config and regenerate:
npm run tools:generate-type-scale -- --out src/styles/global.cssEvery size flag (--base, --base-min, --base-max, --vw-min, --vw-max) takes px or rem —
--base 18px and --base 1.125rem are the same thing. A bare number means rem, and a bare number
large enough to be a px value typed without its unit is rejected rather than guessed at.
Run npm run tools:generate-type-scale -- --help for every flag, and --list-fonts for the
available Fontsource presets.
Preset compatibility
Presets may change:
- The font family behind any of the three roles
- Base size, ratio, viewport range, and step count
- The
.proseoffset
Presets must not:
- Remove a role from the list, or rename one
- Change what a role means —
text-h1is always the largest step - Add a role that duplicates an existing one at a slightly different size
- Reattach the scale to bare
h1–h6element selectors
Rules
- Choose base, ratio and level count once, at the start. Every size after that is a consequence of that choice, not a fresh decision
- Nothing off the scale. No
text-[19px], notext-xlbeside a role class — if the size you want is missing, the ramp is wrong, not the element - One class per element.
text-h2already sets family, size, leading, tracking and weight — do not pair it withtext-xlorfont-bold - Heading hierarchy is monotonic:
text-h1is always larger thantext-h2 - Do not use weight alone for hierarchy — pair it with a size change
- Line height increases as size decreases; the generator enforces this
- Mono is for code and data, never for body copy or headings
- Anything meant to be read is
text-p.text-smallis a hint under a control, not a paragraph