
How to Use CSS Custom Properties for a Typography System?
- Sajjad
- Typography
- 01 Oct, 2026
You inherit a codebase where the stylesheet contains 43 different font-size values, from 13px to 3.25rem, and nobody can tell you which ones are intentional. The brand team wants to swap the heading font next month, and a quick search shows the font family name hard-coded in 60 places across components. Every tweak to line height on the blog means hunting through partials. The typography is not wrong, exactly, but it is not a system. It is a pile of decisions.
CSS custom properties let you turn those decisions into a small set of named values that every component consumes. This article shows how to structure typography custom properties in layers, build a type scale and fluid sizes with native CSS math, apply them through utility classes and components, support themes and user preferences, and avoid the cascade and computation gotchas that trip up most teams.
What Are CSS Custom Properties?
Custom properties (often called CSS variables) are author-defined properties whose names start with two dashes. You declare them like any other property and read them with the var() function:
:root {
--font-body: "Inter", system-ui, sans-serif;
--leading-body: 1.6;
}
body {
font-family: var(--font-body);
line-height: var(--leading-body);
}
Unlike preprocessor variables in Sass or Less, custom properties exist at runtime. They inherit through the DOM, can be changed inside media queries and selectors, can be updated with JavaScript, and are visible in DevTools. Those traits make them a good foundation for a typography system that has to adapt to themes, breakpoints, and user settings.
If you are planning the naming and design-token side across tools like Figma and JSON files, the companion article on typography tokens in a design system covers that layer. This post focuses on the CSS implementation.
Structure: Primitive, Semantic, and Component Layers
The most maintainable typography systems separate values into three layers.
| Layer | Purpose | Example names |
|---|---|---|
| Primitive | Raw values with no meaning attached | --font-size-3, --font-inter, --weight-600 |
| Semantic | Roles in your interface | --text-body, --text-heading-1, --font-heading |
| Component | Local overrides for one component | --card-title-size, --button-font-weight |
Components should consume semantic properties, and semantic properties should point at primitives. When the brand changes, you edit the semantic layer. When one component needs something special, it sets a component property without touching the rest.
:root {
/* Primitives: font stacks */
--font-inter: "Inter", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
--font-source-serif: "Source Serif 4", Georgia, "Times New Roman", serif;
--font-jetbrains: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
/* Primitives: weights */
--weight-regular: 400;
--weight-medium: 500;
--weight-semibold: 600;
--weight-bold: 700;
/* Semantic: roles */
--font-body: var(--font-inter);
--font-heading: var(--font-source-serif);
--font-code: var(--font-jetbrains);
--weight-body: var(--weight-regular);
--weight-heading: var(--weight-bold);
--weight-strong: var(--weight-semibold);
--leading-tight: 1.15;
--leading-snug: 1.3;
--leading-body: 1.6;
--tracking-tight: -0.02em;
--tracking-normal: 0;
--tracking-wide: 0.05em;
}
Building a Type Scale with Native CSS Math
A modular scale multiplies a base size by a fixed ratio for each step. You can calculate it directly in CSS with pow(), which is supported in all current major browsers:
:root {
--text-base: 1rem;
--text-ratio: 1.25; /* major third */
--text-sm: calc(var(--text-base) / var(--text-ratio));
--text-md: var(--text-base);
--text-lg: calc(var(--text-base) * var(--text-ratio));
--text-xl: calc(var(--text-base) * pow(var(--text-ratio), 2));
--text-2xl: calc(var(--text-base) * pow(var(--text-ratio), 3));
--text-3xl: calc(var(--text-base) * pow(var(--text-ratio), 4));
--text-4xl: calc(var(--text-base) * pow(var(--text-ratio), 5));
}
With a 1rem base and a 1.25 ratio, the steps come out to 0.8, 1, 1.25, 1.5625, 1.953, 2.441, and 3.052rem. Change --text-ratio to 1.333 and every heading adjusts at once. The theory behind choosing a ratio is covered in how to build a modular type scale.
Using rem for the base keeps the scale tied to the user's browser font-size setting, which matters for WCAG 1.4.4 (resize text to 200%). Avoid setting the base in px.
Fluid Sizes with clamp()
Large headings that look right on a desktop are usually too big on a phone. Instead of overriding sizes at several breakpoints, give each step a fluid value with clamp():
:root {
--text-xl: clamp(1.25rem, 1.1rem + 0.6vw, 1.5625rem);
--text-2xl: clamp(1.5rem, 1.25rem + 1vw, 1.953rem);
--text-3xl: clamp(1.75rem, 1.35rem + 1.6vw, 2.441rem);
--text-4xl: clamp(2rem, 1.4rem + 2.4vw, 3.052rem);
}
The rem component in the middle value is important. A pure vw value does not respond to browser zoom or user font settings, which can fail WCAG 1.4.4. Mixing rem and vw keeps text scalable. For the math behind choosing the slope and intercept, see fluid typography with CSS clamp().
Mapping the Scale to Semantic Roles
Now give the sizes meaning. Each role bundles size, line height, weight, and tracking, because those values only work in combination. A 3rem heading with body line height looks broken.
:root {
--text-body-size: var(--text-md);
--text-body-leading: var(--leading-body);
--text-h1-size: var(--text-4xl);
--text-h1-leading: var(--leading-tight);
--text-h1-tracking: var(--tracking-tight);
--text-h2-size: var(--text-3xl);
--text-h2-leading: var(--leading-tight);
--text-h2-tracking: var(--tracking-tight);
--text-h3-size: var(--text-2xl);
--text-h3-leading: var(--leading-snug);
--text-h3-tracking: var(--tracking-normal);
--text-caption-size: var(--text-sm);
--text-caption-leading: 1.4;
--text-eyebrow-tracking: var(--tracking-wide);
}
body {
font-family: var(--font-body);
font-size: var(--text-body-size);
font-weight: var(--weight-body);
line-height: var(--text-body-leading);
}
h1,
h2,
h3 {
font-family: var(--font-heading);
font-weight: var(--weight-heading);
}
h1 {
font-size: var(--text-h1-size);
line-height: var(--text-h1-leading);
letter-spacing: var(--text-h1-tracking);
}
h2 {
font-size: var(--text-h2-size);
line-height: var(--text-h2-leading);
letter-spacing: var(--text-h2-tracking);
}
h3 {
font-size: var(--text-h3-size);
line-height: var(--text-h3-leading);
letter-spacing: var(--text-h3-tracking);
}
Role Classes for Non-Heading Elements
Visual style and semantic HTML do not always match. A card title might need to be an h3 for the document outline but look like body-large. Role classes let you apply a text style to any element:
.text-h1,
.text-h2,
.text-h3 {
font-family: var(--font-heading);
font-weight: var(--weight-heading);
}
.text-h2 {
font-size: var(--text-h2-size);
line-height: var(--text-h2-leading);
letter-spacing: var(--text-h2-tracking);
}
.text-caption {
font-size: var(--text-caption-size);
line-height: var(--text-caption-leading);
}
.text-eyebrow {
font-size: var(--text-sm);
font-weight: var(--weight-semibold);
letter-spacing: var(--text-eyebrow-tracking);
text-transform: uppercase;
}
Component-Level Overrides
Components should expose their own custom properties with sensible defaults drawn from the semantic layer. That lets a parent context adjust a component without overriding its internal selectors:
.card {
--card-title-size: var(--text-xl);
--card-title-weight: var(--weight-semibold);
--card-body-size: var(--text-md);
}
.card__title {
font-size: var(--card-title-size);
font-weight: var(--card-title-weight);
line-height: var(--leading-snug);
}
.card__body {
font-size: var(--card-body-size);
}
/* A compact sidebar variant changes only the values */
.sidebar .card {
--card-title-size: var(--text-lg);
--card-body-size: var(--text-sm);
}
No specificity battles, no !important, and the card's own CSS stays untouched.
Theming and Context Switching
Because custom properties cascade, you can redefine the semantic layer for a whole section of the page.
Reading Mode for Long-Form Content
.prose {
--font-body: var(--font-source-serif);
--text-body-size: var(--text-lg);
--text-body-leading: 1.7;
font-family: var(--font-body);
font-size: var(--text-body-size);
line-height: var(--text-body-leading);
max-width: 68ch;
}
Dark Mode Adjustments
Light text on dark backgrounds looks heavier than dark text on light backgrounds. With variable fonts, you can reduce weight slightly in dark mode:
@media (prefers-color-scheme: dark) {
:root {
--weight-body: 380;
--weight-heading: 650;
}
}
Intermediate weights like 380 only work with variable fonts.
Density Settings
[data-density="compact"] {
--text-body-size: var(--text-sm);
--text-body-leading: 1.45;
}
[data-density="comfortable"] {
--text-body-size: var(--text-lg);
--text-body-leading: 1.7;
}
Letting Users Adjust Type with JavaScript
Runtime variables make reader controls trivial. A text-size slider only needs to change one property:
<label for="text-scale">Text size</label>
<input id="text-scale" type="range" min="0.875" max="1.5" step="0.125" value="1" />
:root {
--user-scale: 1;
--text-base: calc(1rem * var(--user-scale));
}
const input = document.getElementById("text-scale");
try {
const saved = localStorage.getItem("text-scale");
if (saved) {
input.value = saved;
document.documentElement.style.setProperty("--user-scale", saved);
}
} catch {}
input.addEventListener("input", (event) => {
const value = event.target.value;
document.documentElement.style.setProperty("--user-scale", value);
try {
localStorage.setItem("text-scale", value);
} catch {}
});
Because every scale step derives from --text-base, the whole system scales together. This complements, rather than replaces, browser zoom and the user's default font size, which you should still respect as described in how to support browser zoom and font preferences.
Integrating with next/font and Tailwind v4
next/font
In Next.js, the variable option exposes a font as a custom property, which slots straight into the primitive layer:
// app/layout.tsx
import { Inter, Source_Serif_4 } from "next/font/google";
const inter = Inter({ subsets: ["latin"], variable: "--font-inter", display: "swap" });
const serif = Source_Serif_4({ subsets: ["latin"], variable: "--font-source-serif", display: "swap" });
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" className={`${inter.variable} ${serif.variable}`}>
<body>{children}</body>
</html>
);
}
next/font defines --font-inter with its own generated family name and fallback, so your semantic --font-body: var(--font-inter) picks it up automatically. Define the primitive stacks in CSS only as fallbacks for non-Next contexts.
Tailwind CSS v4
Tailwind v4's @theme directive is itself built on custom properties. Every token you define becomes both a utility and a CSS variable:
@import "tailwindcss";
@theme inline {
--font-heading: var(--font-source-serif);
--font-body: var(--font-inter);
--text-h1: clamp(2rem, 1.4rem + 2.4vw, 3.052rem);
--text-h1--line-height: 1.15;
--text-h1--letter-spacing: -0.02em;
}
That generates font-heading, font-body, and text-h1 utilities, with the line height and tracking bundled into text-h1. The inline keyword matters here: because these tokens reference other variables (the ones next/font sets), it makes the generated utilities use the referenced value directly instead of a variable that was resolved on :root.
Gotchas and How to Avoid Them
Custom Properties Cannot Be Used in Media Query Conditions
@media (min-width: var(--bp-md)) does not work. Media query conditions are evaluated outside the element cascade. Use literal values, or @custom-media through a build tool such as PostCSS or Lightning CSS.
Relative Units Resolve Where var() Is Used
An unregistered custom property stores tokens, not computed values. If you write --gap: 1em on :root and use margin: var(--gap) on an h1, the em resolves against the h1's font size, not the root's. That is usually helpful for spacing tied to type, but surprising if you expected a fixed value. Use rem when you want consistency.
Typed Properties with @property
Registering a property gives it a type, an initial value, and controlled inheritance:
@property --leading-body {
syntax: "<number>";
inherits: true;
initial-value: 1.6;
}
A registered property with an invalid value falls back to its initial value instead of making the whole declaration invalid. Registered <length> properties compute at the element where they are declared, which reverses the em behaviour described above, so register deliberately.
Invalid at Computed-Value Time
If var(--text-h2-size) resolves to something that is not a valid font size (a typo, or a missing property with no fallback), the browser does not fall back to the previous declaration. The property resets to its inherited or initial value. Provide fallbacks for anything optional: var(--card-title-size, var(--text-xl)).
Do Not Over-Abstract
A system with 200 typography properties is as hard to maintain as 43 hard-coded font sizes. A practical target is 6–8 scale steps, 3–4 line heights, 3–4 weights, 2–3 font families, and a semantic role for each text style you actually use.
Typography Custom Properties FAQ
Either works, since the root selector targets the html element. The root pseudo-class has higher specificity than the html type selector, which slightly reduces the chance of an accidental override. Pick one and use it consistently.
Not in any way that matters for typography. Browsers resolve custom properties efficiently. The only cost to watch is changing a property on the root element very frequently, such as on every animation frame, because it can trigger style recalculation across the whole page.
Design tokens are the platform-agnostic decisions, often stored as JSON and shared between design tools, web, and native apps. CSS custom properties are one way to deliver those tokens to the browser. Many teams generate custom properties from a token file automatically.
Yes. You can build the shorthand from variables, for example weight, size, line height, and family. Remember that the shorthand resets every font sub-property it does not mention, so make sure each variable resolves to a valid value or the whole declaration fails.
Treat a token file as the source of truth, export variables from Figma into it, and generate CSS custom properties from that file during your build. That way designers and developers edit the same values instead of copying numbers by hand.
Only components that are reused in different contexts and genuinely need adjustment. Simple components can consume semantic variables directly. Exposing a handful of component properties for things like title size is enough for most design systems.
Conclusion
A typography system built on custom properties replaces scattered values with a small vocabulary the whole codebase shares. Primitives hold raw stacks, weights, and scale steps. Semantic properties map those primitives to roles like body, heading, and caption. Components consume roles and expose a few local overrides. Change the brand font, the scale ratio, or the body line height, and you edit one line instead of sixty.
The runtime nature of custom properties is what makes this better than a preprocessor setup. The same system can switch to reading mode inside an article, lighten weights in dark mode, tighten up in a compact layout, and respond to a reader's text-size preference, all without duplicating a single rule. Keep the vocabulary small, use rem and clamp() so it stays accessible, and let the cascade do the rest.
Here are some useful references for going deeper on CSS custom properties for typography:
- MDN Web Docs: Using CSS custom properties — syntax, inheritance, fallbacks, and JavaScript access.
- MDN Web Docs: @property — registering typed custom properties.
- W3C: CSS Custom Properties for Cascading Variables Module Level 1 — the specification, including invalid at computed-value time behaviour.
- Tailwind CSS: Theme variables — how Tailwind v4 turns @theme tokens into custom properties and utilities.
- Next.js Docs: Font optimization — using next/font with CSS variables.


