EmpireUI
Get Pro
← Blog9 min read#typography#responsive#fluid

Responsive Typography System: fluid type, scale ratios, viewport units

Build a type system that scales beautifully across every viewport — fluid clamp(), modular ratios, viewport units, and tokens that don't fight your design.

Large bold typographic characters on a dark editorial layout

Why your current type system is probably broken

You've been there. Design hands off a Figma file with H1 at 72px, body at 16px, and nothing in between documented. Mobile gets H1 at 36px. Someone writes a bunch of media query overrides, the breakpoints don't match the grid, and six months later you've got 14 different font-size declarations scattered across your codebase. Sound familiar?

The root problem isn't laziness — it's that most teams treat typography as a decoration layer rather than a system. A real type system has three things: a consistent scale ratio, fluid interpolation between viewport extremes, and tokens that the whole codebase references. Miss any one of those and you're back to whack-a-mole CSS.

Honestly, most design systems nail the scale on desktop and completely ignore what happens at 375px. The heading that looks majestic at 1440px is either illegibly small or awkwardly large on mobile. Fluid typography solves this, and it's been native CSS since 2021 — you don't need a library for it.

Worth noting: this isn't just a cosmetic concern. The WCAG accessibility guide requires text to remain readable and not overflow at 400% zoom. A well-built fluid type system handles that gracefully without any extra work.

Modular scale ratios — picking the right one

A modular scale is just a geometric sequence. You pick a base size and a ratio, and every step is base × ratio^n. The ratio is everything. Too tight (like 1.067, the minor second) and your headings barely stand out from body text. Too aggressive (like 1.618, the golden ratio) and your H1 is absurdly large on mobile.

For most UI work, the major third (1.25) or perfect fourth (1.333) hits the sweet spot. The perfect fourth gives you these steps off a 16px base: 12px, 16px, 21px, 28px, 37px, 50px, 67px. That's a clean visual hierarchy without anything looking ridiculous at small sizes. If you're building something editorial — a blog, a marketing page — you can push to the perfect fifth (1.5) for more drama.

Quick aside: the year 2024 saw a surge in variable fonts, which make scale ratios even more powerful because you can animate font-weight across the same scale step. If you haven't explored variable fonts yet, it's worth the detour.

Here's the scale defined as CSS custom properties, which you can reference anywhere in your codebase — including in CSS variables system patterns: ``css :root { --ratio: 1.333; /* perfect fourth */ --base: 1rem; /* 16px */ --step--2: calc(var(--base) / var(--ratio) / var(--ratio)); --step--1: calc(var(--base) / var(--ratio)); --step-0: var(--base); --step-1: calc(var(--base) * var(--ratio)); --step-2: calc(var(--base) * pow(var(--ratio), 2)); --step-3: calc(var(--base) * pow(var(--ratio), 3)); --step-4: calc(var(--base) * pow(var(--ratio), 4)); --step-5: calc(var(--base) * pow(var(--ratio), 5)); } ` Note that pow()` in CSS calc() is supported in all modern browsers as of 2024. If you need to support older targets, compute the values manually or use a PostCSS plugin.

Fluid type with clamp() — the only formula you need

The clamp() function takes three arguments: a minimum value, a preferred value, and a maximum value. The preferred value is where the magic lives — it's a viewport-relative expression that smoothly interpolates between your min and max as the viewport grows.

The formula everyone should have memorised by now looks like this: ``css font-size: clamp( [min-size], [min-size] + ([max-size] - [min-size]) * ((100vw - [min-viewport]) / ([max-viewport] - [min-viewport])), [max-size] ); ` In practice, you're almost always targeting a min viewport of 320px and a max of 1440px. Plug those in for your H1 — say, 32px minimum and 72px maximum — and you get: `css h1 { font-size: clamp( 2rem, 2rem + 40 * ((100vw - 320px) / 1120), 4.5rem ); } ` Or, if you want the tighter vi unit syntax that some teams prefer for its container-query friendliness: `css h1 { font-size: clamp(2rem, 4.5vi + 0.75rem, 4.5rem); } ``

In practice, the vi version is less readable but more composable once you're generating values programmatically. For hand-authored CSS, the longform is easier to debug. The fluid-typography-clamp article has a deep-dive on the math — read it if you want to understand the derivation properly rather than just copying the formula.

One trap to avoid: don't use vw as the *only* preferred unit without a fixed offset. font-size: 5vw sounds cute but it means at 320px your font is 16px and at 1px your font is 0.05px. Always add a rem offset so the text never collapses to zero.

Viewport units beyond vw — dvh, svh, and the newer stuff

Most typography guides stop at vw and vh. But 2023 brought proper browser support for the dynamic viewport units, and they change some edge cases significantly. dvh (dynamic viewport height) accounts for the browser chrome on mobile — the address bar that appears and disappears as you scroll. svh is the small viewport height (address bar visible), lvh is the large viewport height (address bar hidden).

For typography specifically, dvh matters when you're sizing display text to fill the screen — hero sections, full-screen slides, that kind of thing. Using 100vh for that used to mean the text got clipped by the browser chrome on iOS. 100dvh fixes it. It's a small change with a real UX payoff.

The vi and vb units (inline and block axis equivalents of vw/vh) deserve more attention than they get. vi is particularly useful in a multilingual app where writing direction might flip — it always refers to the inline axis, so your fluid type formula stays correct in RTL layouts without modification.

That said, don't over-engineer the units on a standard LTR product. vw works fine. The newer units are tools for specific problems, not defaults you should chase for novelty's sake.

Look, the takeaway is simple: clamp() with vw covers 95% of real-world fluid type needs. Know the other units exist, reach for them when you actually have the problem they solve.

Assembling a full token set in CSS and Tailwind

Once you've got your scale steps and fluid formulas, the next move is wiring them up as a consistent token layer. The goal is that no one in your codebase ever writes a raw px value for font-size again — they always reference a token.

In plain CSS with custom properties: ``css :root { /* Fluid type tokens */ --text-xs: clamp(0.694rem, 0.694rem + 0vi, 0.75rem); --text-sm: clamp(0.833rem, 0.833rem + 0.2vi, 0.889rem); --text-base: clamp(1rem, 1rem + 0.5vi, 1rem); --text-lg: clamp(1.2rem, 1.2rem + 0.8vi, 1.333rem); --text-xl: clamp(1.44rem, 1.44rem + 1.2vi, 1.777rem); --text-2xl: clamp(1.728rem, 1.728rem + 1.8vi, 2.369rem); --text-3xl: clamp(2.074rem, 2.074rem + 2.8vi, 3.157rem); --text-4xl: clamp(2.488rem, 2.488rem + 4vi, 4.209rem); } body { font-size: var(--text-base); } h4 { font-size: var(--text-xl); } h3 { font-size: var(--text-2xl); } h2 { font-size: var(--text-3xl); } h1 { font-size: var(--text-4xl); } ` In Tailwind, you'd extend the fontSize config in tailwind.config.js: `js module.exports = { theme: { extend: { fontSize: { 'fluid-sm': ['clamp(0.833rem, 0.833rem + 0.2vi, 0.889rem)', { lineHeight: '1.5' }], 'fluid-base': ['clamp(1rem, 1rem + 0.5vi, 1rem)', { lineHeight: '1.6' }], 'fluid-lg': ['clamp(1.2rem, 1.2rem + 0.8vi, 1.333rem)', { lineHeight: '1.4' }], 'fluid-xl': ['clamp(1.44rem, 1.44rem + 1.2vi, 1.777rem)', { lineHeight: '1.3' }], 'fluid-2xl': ['clamp(1.728rem, 1.728rem + 1.8vi, 2.369rem)', { lineHeight: '1.2' }], 'fluid-3xl': ['clamp(2.074rem, 2.074rem + 2.8vi, 3.157rem)', { lineHeight: '1.15' }], 'fluid-4xl': ['clamp(2.488rem, 2.488rem + 4vi, 4.209rem)', { lineHeight: '1.05' }], }, }, }, }; ``

One more thing — line height should scale inversely with font size. Display headings need tight leading (1.05–1.15), body text needs loose leading (1.5–1.7). Hard-coding line-height: 1.5 everywhere is one of those subtle mistakes that makes designs feel off without anyone being able to explain why.

The component library at Empire UI applies exactly this token pattern — you can inspect the glassmorphism components at /glassmorphism to see how the fluid text tokens interact with backdrop-filter surfaces without the text becoming illegible at small sizes.

Line length, spacing, and the things people forget

A type system isn't just font-size. The full picture includes line-height, line-length (measure), letter-spacing, and the vertical rhythm between elements. Most teams nail the size part and leave the rest as gut-feel magic numbers.

Measure — the line length in characters — should stay between 45 and 75 characters for comfortable reading. In CSS, that's roughly max-width: 65ch on your prose container. The ch unit is the width of the 0 character in the current font, so it automatically adapts as font size changes. No magic pixel values needed: ``css .prose { max-width: 65ch; margin-inline: auto; } ``

Letter-spacing is the other forgotten dimension. At small sizes (under 14px) you typically want slightly positive tracking for legibility. At large display sizes, negative tracking tightens the visual texture. A safe default: ``css :root { --tracking-tight: -0.04em; --tracking-normal: 0em; --tracking-wide: 0.05em; } h1, h2 { letter-spacing: var(--tracking-tight); } body { letter-spacing: var(--tracking-normal); } .label { letter-spacing: var(--tracking-wide); font-size: var(--text-xs); } ``

Vertical spacing between type elements is where the spacing system CSS and your type system should talk to each other. The spacing between an H2 and the paragraph that follows it shouldn't be an arbitrary 24px — it should be --step-1 or 1.5em, derived from the same scale. That's what makes a system feel like a *system* rather than a collection of coincidences.

Putting it together in a React component

A type system lives or dies on how easy it is to use. If reaching for the right token takes more than 2 seconds, people will stop and write raw values. The token layer needs to be invisible and automatic.

One pattern that works well is a <Text> component that maps semantic roles to tokens: ``tsx type TextVariant = 'display' | 'heading' | 'subheading' | 'body' | 'caption' | 'label'; const variantClasses: Record<TextVariant, string> = { display: 'text-fluid-4xl font-bold tracking-tight leading-none', heading: 'text-fluid-3xl font-semibold tracking-tight leading-tight', subheading: 'text-fluid-2xl font-semibold leading-snug', body: 'text-fluid-base font-normal leading-relaxed', caption: 'text-fluid-sm font-normal leading-normal text-muted-foreground', label: 'text-fluid-sm font-medium tracking-wide uppercase', }; interface TextProps extends React.HTMLAttributes<HTMLElement> { variant?: TextVariant; as?: React.ElementType; } export function Text({ variant = 'body', as: Tag = 'p', className, ...props }: TextProps) { return ( <Tag className={${variantClasses[variant]} ${className ?? ''}} {...props} /> ); } ``

This keeps the semantic HTML flexible (as prop) while enforcing the token layer by default. You'd use it as <Text variant="heading" as="h2">Section title</Text> — readable, searchable, and impossible to accidentally use the wrong size.

The as prop pattern also pairs cleanly with the component API design principles covered in component-api-design. Polymorphic components that accept an as prop are one of those patterns that seem over-engineered until you've worked on a project without them.

Honestly, the biggest win from a typed Text component isn't the DX — it's that design reviews get faster. When every heading on every page uses the same variant, you stop having the conversation about why one section has a 34px heading and another has 36px. The system makes those decisions for you.

FAQ

What's the difference between fluid typography and responsive typography?

Responsive typography uses discrete breakpoints — the font jumps from 36px to 48px at 768px. Fluid typography uses clamp() to interpolate smoothly between a min and max size across the entire viewport range. No jumps, no breakpoints needed.

Can I use clamp() with Tailwind CSS?

Yes. Extend the fontSize key in tailwind.config.js with clamp() strings and Tailwind will generate utility classes for them. You can also write arbitrary values inline with text-[clamp(1rem,2vw,1.5rem)] if you're just doing it once.

What type scale ratio should I use?

Major third (1.25) for dense UIs like dashboards, perfect fourth (1.333) for most product and marketing sites, perfect fifth (1.5) for editorial or display-heavy work. Start with 1.333 and adjust if the jumps feel too dramatic.

How do I handle fluid typography in RTL layouts?

Use the vi unit (inline axis) instead of vw in your clamp() preferred value. The vi unit automatically maps to the horizontal axis in LTR and RTL, so your formula stays correct without any direction-specific overrides.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

Typography Design System: Scale, Responsive Size, Line Height TokensFluid Typography With clamp(): No More Responsive Font BreakpointsMonorepo Design System: Shared Packages, Storybook, PublishingDark Mode Color Palette System: Semantic Tokens That Actually Work