Typography Design System: Scale, Responsive Size, Line Height Tokens
Build a real typography design system with CSS custom properties, fluid type scales, and line-height tokens that hold up across every screen size.
Why Most Typography Systems Break at 1440px
Here's a scenario you've probably lived: someone on the design team sets up a 12-step type scale in Figma, it looks perfect at 1440px, and then a developer hard-codes font-size: 48px on every heading. By the time it hits a 375px phone screen, the hero title is eating half the viewport and nobody can figure out why. The answer is simple — there was never actually a system, just a one-off collection of pixel values.
A real typography design system is three things working together: a scale (the mathematical relationship between your sizes), tokens (named CSS custom properties that the whole codebase agrees on), and responsive rules (how those tokens shift across breakpoints or viewport widths). Skip any one of those legs and you're back to the ad-hoc pixel problem within six months.
Honestly, the hardest part isn't the math. It's getting the team to stop writing font-size: 18px inline and start writing font-size: var(--text-lg). Discipline first, automation second. The tooling is the easy bit.
Worth noting: if you're building on top of a component library like Empire UI, a lot of this groundwork is already done for you — every style variant ships with coherent type tokens baked in. But understanding the system underneath makes you a much sharper consumer of those tokens.
Choosing a Type Scale Ratio
The classic approach is a modular scale — you pick a base size and a ratio, then multiply or divide to generate every step. The most common ratios are the Major Second (1.125), Minor Third (1.2), Major Third (1.25), and Perfect Fourth (1.333). For UI work in 2026, the Major Third and Perfect Fourth are the most useful. Minor Second is too tight to feel intentional at small sizes; Perfect Fifth (1.5) gets wild fast at the large end.
Start with a 16px base (the browser default, and there's a reason that default has survived thirty years). Apply a Perfect Fourth ratio and you get: 10.67, 12, 13.33, 16, 21.33, 28.44, 37.93, 50.57px. Round those sensibly to 11, 12, 14, 16, 21, 28, 38, 48 and you have a solid eight-step scale covering xs through 5xl.
/* typography-scale.css */
:root {
/* Base: 16px, Ratio: Perfect Fourth (1.333) */
--text-xs: 0.694rem; /* ~11px */
--text-sm: 0.833rem; /* ~13px */
--text-base: 1rem; /* 16px */
--text-lg: 1.2rem; /* ~19px */
--text-xl: 1.44rem; /* ~23px */
--text-2xl: 1.728rem; /* ~28px */
--text-3xl: 2.074rem; /* ~33px */
--text-4xl: 2.488rem; /* ~40px */
--text-5xl: 2.986rem; /* ~48px */
}That said, pure modular scales are a starting point, not a law. If your --text-sm lands at 13.33px and it feels wrong in your UI, round it to 14px. The goal is a system the team can predict and remember, not mathematical purity for its own sake.
One more thing — Tailwind's default scale is already close to a Perfect Fourth. If you're using Tailwind, you can map your custom properties directly onto its fontSize config in tailwind.config.ts, so you get both worlds: your token naming convention and Tailwind's utility generation.
Fluid Type: clamp() Done Right
Static breakpoints feel like a compromise. You set font-size: 48px on desktop and font-size: 28px on mobile and everything in between jumps abruptly at 768px. clamp() solves this with a single declaration: a minimum size, a preferred viewport-relative size, and a maximum. The type grows and shrinks continuously as the viewport changes.
The preferred value is the tricky part. You want a slope — a rate of change — that gets you from your mobile size to your desktop size across the viewport range you care about. The formula for the preferred value is: preferred = (max - min) / (max-vw - min-vw) * 100vw + (min - slope * min-vw). That's algebra, so just use a calculator. The numbers that work well for a 375px to 1440px range with --text-5xl going from 32px to 60px look like this:
:root {
/* Fluid scale: 375px viewport → 1440px viewport */
--text-base: clamp(1rem, 0.95rem + 0.22vw, 1.125rem);
--text-xl: clamp(1.25rem, 1.1rem + 0.65vw, 1.5rem);
--text-2xl: clamp(1.5rem, 1.2rem + 1.3vw, 2rem);
--text-3xl: clamp(1.875rem, 1.4rem + 2.1vw, 2.5rem);
--text-4xl: clamp(2.25rem, 1.5rem + 3.3vw, 3.5rem);
--text-5xl: clamp(2rem, 1rem + 4.44vw, 3.75rem);
}In practice, you don't need to fluid-ize every step. --text-xs, --text-sm, and --text-base are fine static — they're body copy, captions, and labels, and those benefit from *stability* more than fluid scaling. Reserve clamp() for headings and display sizes (3xl and up) where the difference between mobile and desktop actually matters.
Quick aside: if you're using a design tool that exports tokens, check whether it supports fluid values. Tokens Studio for Figma added clamp() support in 2024, so you can author fluid tokens visually and export them directly. That's a genuine quality-of-life upgrade for cross-functional teams.
Line Height Tokens That Actually Make Sense
Line height is where most type systems get lazy. You'll see line-height: 1.5 hard-coded everywhere and nobody thinks twice about it. But 1.5 on a 48px heading creates 72px of line height — that's way too loose. Headings need tighter leading. Body text needs more air. The token system should encode that opinion, not leave it up to individual developers.
The pattern that works well is three semantic tokens tied to use cases, not to size steps:
:root {
/* Line height tokens — unitless multipliers */
--leading-none: 1; /* Display / giant hero text */
--leading-tight: 1.2; /* Headings h1–h3 */
--leading-snug: 1.375; /* Subheadings, large UI labels */
--leading-normal: 1.5; /* Body copy, default prose */
--leading-relaxed: 1.625; /* Long-form reading, blog posts */
--leading-loose: 2; /* Spaced lists, code comments */
}Then document the pairing convention: anything using --text-3xl and above gets --leading-tight. --text-base and --text-lg get --leading-normal. Long-form content gets --leading-relaxed. Write that rule into your design system docs and lint against deviations if you can. Teams that skip this end up with twelve different line-height values scattered across their stylesheets by year two.
Look, line-height on unitless values is intentional — it multiplies relative to the *computed* font size of the element, not to the root. So when your heading shifts from 48px to 32px on mobile via clamp(), the leading scales with it automatically. That's the behavior you want. Never use px or rem line-height values in a fluid type system.
Composing Font Weight and Letter Spacing Tokens
A type token system isn't complete without weight and tracking (letter-spacing). These rarely need many stops — three weight tokens and three tracking tokens cover basically every UI typography scenario you'll encounter.
:root {
/* Weight tokens */
--font-normal: 400;
--font-medium: 500;
--font-semibold: 600;
--font-bold: 700;
/* Letter spacing tokens */
--tracking-tight: -0.025em; /* Large headings, display */
--tracking-normal: 0em; /* Body, UI labels */
--tracking-wide: 0.05em; /* Caps, overlines, badges */
--tracking-wider: 0.1em; /* Spaced caps UI */
}The -0.025em tight tracking on large headings is not an aesthetic whim — it's correcting a real optical problem. At 48px or larger, the default letter-spacing creates gaps that the eye reads as separated words. Pull that spacing in by 0.02–0.04em and the heading coheres into a single visual unit. You can see this in basically every well-executed type system from Apple, Linear, and Vercel.
Wide tracking is specifically for all-caps contexts. Never apply --tracking-wide to mixed-case body text — it makes prose exhausting to read. That sounds obvious, but you'll catch it in production code more often than you'd expect.
Worth noting: if you want to see how tracking and weight interact with different aesthetic styles — claymorphism headings vs. cyberpunk display text — the style hubs on Empire UI are a useful reference. Check out neobrutalism and cyberpunk especially; both make aggressive typographic choices that are instructive even if you're building something more restrained.
Wiring Tokens Into a Component System
Tokens sitting in a :root block do nothing until components actually consume them. The integration pattern I'd recommend is a semantic layer between your raw scale tokens and your component styles. Don't use --text-3xl directly in a component — map it to a semantic role first.
/* semantic-type.css — the bridge layer */
:root {
/* Role-based aliases pointing at scale tokens */
--type-display: var(--text-5xl);
--type-h1: var(--text-4xl);
--type-h2: var(--text-3xl);
--type-h3: var(--text-2xl);
--type-h4: var(--text-xl);
--type-body: var(--text-base);
--type-small: var(--text-sm);
--type-caption: var(--text-xs);
/* Paired leading for each role */
--type-display-leading: var(--leading-none);
--type-h1-leading: var(--leading-tight);
--type-h2-leading: var(--leading-tight);
--type-h3-leading: var(--leading-snug);
--type-body-leading: var(--leading-normal);
--type-small-leading: var(--leading-normal);
--type-caption-leading: var(--leading-snug);
}
/* Usage in a component */
.card-title {
font-size: var(--type-h3);
line-height: var(--type-h3-leading);
font-weight: var(--font-semibold);
letter-spacing: var(--tracking-tight);
}The semantic layer buys you refactoring flexibility. If you decide --type-h1 should be --text-5xl instead of --text-4xl, you change one line. Every component using --type-h1 updates immediately, with zero component-level changes. That's the actual value of the indirection — not abstraction for its own sake.
In a React + TypeScript project, you'd typically enforce this through a <Text> or <Heading> component that accepts a variant prop. The variants map directly to your semantic tokens. That way your component API is the contract, not raw CSS variables scattered across hundreds of JSX files.
If you're building on Empire UI, the components already follow this pattern — the underlying token structure means you can theme the entire library by overriding a small set of CSS custom properties at the root level. Browse the gradient generator and box shadow generator to see how consistent the visual language is across tools that share the same token foundation.
Testing and Documenting Your Type System
A type system no one can find or understand will drift into chaos inside a year. Documentation isn't optional — it's how the system propagates to every new developer who joins the team. The minimum viable type docs are a living style guide page that renders every token combination at real size, with the token name visible next to each specimen.
For testing, you want to catch two things: tokens being used outside the system (raw pixel values in component styles) and token pairings that violate your documented rules (body text with --leading-tight, headings with --tracking-wide on mixed case). Stylelint handles both with a custom plugin or the stylelint-declaration-block-no-ignored-properties rule extended to cover your token conventions.
// .stylelintrc.json
{
"rules": {
"declaration-property-value-disallowed-list": {
"font-size": ["/(\\d+)px/"],
"line-height": ["/(\\d+)px/", "/(\\d+)rem/"]
}
}
}That regex-based rule blocks any font-size or line-height declaration using raw px or rem values — forcing developers toward tokens. It's a blunt instrument but it works. You'll need to add it to your CI pipeline to enforce it, otherwise it's advisory noise.
Honestly, the real test of a type system is whether a developer new to the codebase can produce a correctly-typed page component without asking anyone for help. If they can read the docs, find the tokens, and ship something consistent on their first attempt — you built a real system. If they're still Slacking the designer for the heading size, you've got more work to do.
FAQ
A type scale is just the set of size values — the numbers. A type system includes the scale, the tokens that name those values, the rules for how they pair with line-height and weight, and how they respond across screen sizes. The scale is one component of the system, not the whole thing.
No. Reserve clamp() for display and heading sizes — text-3xl and up. Body copy, labels, and captions are more readable when they stay stable. Fluid scaling on small text at narrow viewports can push already-small sizes below the 12px threshold where readability falls off.
Unitless line-height multiplies against the element's own computed font-size. So when a heading scales down from 48px to 32px via clamp(), the leading scales with it automatically. Fixed-unit line-heights don't do that — you'd need to update them separately at every breakpoint.
Eight to ten steps covers almost every real UI need: xs, sm, base, lg, xl, 2xl, 3xl, 4xl, and maybe 5xl for hero/display. Fewer than six and you start making exceptions constantly. More than twelve and nobody can remember which token to reach for, which defeats the purpose.