Core
Primary is the anchor
Settings

How the five derived colors are placed around the anchor.

Theme builder

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-eyebrow

Foundations

text-h1

Design that scales

text-lead

One ratio, chosen once and applied everywhere.

text-p

Body 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-small

Helper 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:

html
<!-- 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 h1h6 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 1

Design that scales

text-h22.488rem → 3.052rem (39.8px → 48.8px) · lh 1.15 · 700Heading level 2

Design that scales

text-h32.074rem → 2.441rem (33.2px → 39.1px) · lh 1.2 · 600Heading level 3

Design that scales

text-h41.728rem → 1.953rem (27.6px → 31.3px) · lh 1.25 · 600Heading level 4

Design that scales

text-h51.44rem → 1.563rem (23px → 25px) · lh 1.3 · 600Heading level 5

Design that scales

text-h61.2rem → 1.25rem (19.2px → 20px) · lh 1.35 · 600Heading level 6

Design that scales

Text roles

text-lead1.147rem → 1.182rem (18.3px → 18.9px) · lh 1.6 · 400Introductory paragraph under a heading

The quick brown fox jumps over the lazy dog.

text-quote1.147rem → 1.182rem (18.3px → 18.9px) · lh 1.7 · 400Pull quotes and blockquotes

The quick brown fox jumps over the lazy dog.

text-p1rem (16px) · lh 1.7 · 400Default body copy

The quick brown fox jumps over the lazy dog.

text-small0.915rem (14.6px) · lh 1.5 · 400Helper text under a control. Never a paragraph

The quick brown fox jumps over the lazy dog.

text-eyebrow1.095rem → 1.118rem (17.5px → 17.9px) · lh 1.4 · 600Kicker above a headline

The quick brown fox jumps over the lazy dog.

text-code0.946rem (15.1px) · lh 1.5 · 400Inline code, IDs, metrics

The quick brown fox jumps over the lazy dog.

text-control-sm0.757rem (12.1px) · lh 1 · 500Small control labels — dense toolbars, table row actions

The quick brown fox jumps over the lazy dog.

text-control0.875rem (14px) · lh 1 · 500Control labels — the default button size

The quick brown fox jumps over the lazy dog.

text-control-lg1rem (16px) · lh 1 · 500Large control labels — hero calls to action

The quick brown fox jumps over the lazy dog.

Reference

UtilitySizeLine heightTrackingWeightUse
text-h1clamp(2.986rem, 2.71rem + 1.381vw, 3.815rem)1.1-0.03700Heading level 1
text-h2clamp(2.488rem, 2.301rem + 0.939vw, 3.052rem)1.15-0.0208700Heading level 2
text-h3clamp(2.074rem, 1.951rem + 0.613vw, 2.441rem)1.2-0.0133600Heading level 3
text-h4clamp(1.728rem, 1.653rem + 0.375vw, 1.953rem)1.25-0.0075600Heading level 4
text-h5clamp(1.44rem, 1.399rem + 0.204vw, 1.563rem)1.3-0.0033600Heading level 5
text-h6clamp(1.2rem, 1.183rem + 0.083vw, 1.25rem)1.350600Heading level 6
text-leadclamp(1.147rem, 1.135rem + 0.059vw, 1.182rem)1.60400Introductory paragraph under a heading
text-quoteclamp(1.147rem, 1.135rem + 0.059vw, 1.182rem)1.70400Pull quotes and blockquotes
text-p1rem1.70400Default body copy
text-small0.915rem1.50400Helper text under a control. Never a paragraph
text-eyebrowclamp(1.095rem, 1.088rem + 0.038vw, 1.118rem)1.40.08600Kicker above a headline
text-code0.946rem1.50400Inline code, IDs, metrics
text-control-sm0.757rem10500Small control labels — dense toolbars, table row actions
text-control0.875rem10500Control labels — the default button size
text-control-lg1rem10500Large 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.

RatioIntervaltext-h3text-h1
1.067Minor Second20.7px23.6px
1.125Major Second25.6px32.4px
1.2Minor Third33.2px47.8px
1.25Major Third39.1px61px
1.333Perfect Fourth50.5px89.8px
1.414Augmented Fourth64px127.9px
1.5Perfect Fifth81px182.3px
1.618Golden Ratio109.7px287.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 h1 is meant to be an event. Plan on three or four levels rather than six.
  • 1.618 — display. One heading, one paragraph, one page.
bash
npm run tools:generate-type-scale -- --ratio 1.333 --steps 4

Two 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:

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

bash
npm run tools:generate-type-scale -- --base 18px      # same as --base 1.125rem

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

TokenRoleUsed by
--font-headingDisplay and headingstext-h1text-h6, text-eyebrow
--font-bodyReading text and control labelstext-lead, text-p, text-small, text-quote, text-control-smtext-control-lg, and body
--font-monoCode and datatext-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.

bash
npm i @fontsource-variable/fraunces @fontsource-variable/inter
js
// src/layouts/BaseLayout.astro — import once, in the root layout
import '@fontsource-variable/fraunces';
import '@fontsource-variable/inter';
css
@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:

bash
npm run tools:generate-type-scale -- --ratio-max 1.333 --font inter --prose

Long-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:

bash
npm run tools:generate-type-scale -- --out src/styles/global.css

Every 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 .prose offset

Presets must not:

  • Remove a role from the list, or rename one
  • Change what a role means — text-h1 is always the largest step
  • Add a role that duplicates an existing one at a slightly different size
  • Reattach the scale to bare h1h6 element 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], no text-xl beside a role class — if the size you want is missing, the ramp is wrong, not the element
  • One class per element. text-h2 already sets family, size, leading, tracking and weight — do not pair it with text-xl or font-bold
  • Heading hierarchy is monotonic: text-h1 is always larger than text-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-small is a hint under a control, not a paragraph