Tools & Workflow
CSS Typography Best Practices Build a system that holds up.
A practical CSS setup for type that scales with the layout, stays readable while fonts load, and survives real content and accessibility settings.
The short read
Build by role
Define the few text jobs your interface needs before generating a large type scale.
Make fluid type bounded
Use clamp() for deliberate growth, with minimum and maximum values that still work at zoom and narrow widths.
Test the conditions
Check loading, fallback metrics, 200% text resize, user-applied spacing, contrast, and real content—not only the happy-path screenshot.

Build the system before polishing the syntax
Good CSS cannot decide what deserves emphasis. Start by naming the text roles in the interface: display, section heading, body, label, caption, and control text. Most products need fewer distinct styles than their design files suggest.
Define those roles as tokens, then let each component use the role it needs. This keeps a one-off card heading from quietly becoming a sixth heading level and makes the implementation easier to tune as a system.
:root {
/* Start with roles, not a giant generated scale. */
--font-body: "Body Sans", system-ui, sans-serif;
--font-display: "Display Serif", Georgia, serif;
--text-label: 0.875rem;
--text-body: 1rem;
--text-lead: 1.25rem;
--text-section: clamp(1.75rem, 1.35rem + 1.4vw, 2.75rem);
--text-display: clamp(2.5rem, 1.5rem + 4vw, 5.5rem);
--leading-tight: 1.08;
--leading-body: 1.6;
--measure: 68ch;
}
body {
font-family: var(--font-body);
font-size: var(--text-body);
line-height: var(--leading-body);
}
h1, h2, h3 {
font-family: var(--font-display);
line-height: var(--leading-tight);
text-wrap: balance;
}
.prose {
max-inline-size: var(--measure);
}Use a bounded fluid scale—not fluid type everywhere
CSS clamp() combines a minimum, a preferred fluid value, and a maximum. It is useful when a display heading should grow with the available space without jumping through several breakpoints.
It is not a reason to make every label and paragraph drift with the viewport. Stable body and interface text often creates a calmer, more predictable system. Use fluid sizing where the composition benefits from it, then test the values between the endpoints—not only at desktop and mobile.
Use a stable size
Reading and interface text
- Body copy, form labels, controls, and captions
- Dense components where wrapping changes the layout
- Text whose job should remain visually steady
Consider clamp()
Composition-led display text
- Page titles and major section headings
- Editorial moments with meaningful room to grow
- Values with tested minimum and maximum bounds
Set the reading conditions around the font
A typeface can look perfectly readable in a specimen and struggle in a real interface. Line length, line height, size, weight, spacing, contrast, and the language all change the result. That is why a single universal number rarely solves the page.
Useful starting points—then read the page
- Long-form body copy
- Start near 1rem, a unitless line height around 1.5–1.65, and a measure around 60–70ch.Adjust for the typeface, content, language, device, and reading context.
- Large headings
- Tighten line height and consider slightly tighter tracking only after testing the real words.Display settings do not transfer safely to small text.
- Labels and controls
- Favor clarity, adequate size, and enough space for translation and user text settings.Do not use all caps or tracking as a substitute for hierarchy.
- Narrow mobile columns
- Recheck wrapping, orphaned words, button height, and the vertical rhythm created by longer lines.A desktop token can be valid while a component still needs a different composition.
Make font loading a deliberate tradeoff
A web font affects more than style. It can delay visible text, swap after the page appears, and move the layout when its metrics differ from the fallback. The right loading strategy depends on how important that face is and what happens when it arrives late.
Choose font-display by consequence
- Text must appear immediately
- Consider swap, deliver the font early, and match the fallback metrics.The custom face will eventually appear, but a late swap can move text and controls.
- Stability matters more than the custom face
- Consider optional.The browser may keep the fallback on a slow visit instead of introducing a late swap.
- Brand display face is essential
- Keep the request small and early, and test the loading state explicitly.Do not accept invisible or shifting content merely because the font is expressive.
@font-face {
font-family: "Project Sans";
src: url("/fonts/project-sans-variable.woff2") format("woff2");
font-style: normal;
font-weight: 300 800;
font-display: swap;
}
:root {
--font-body: "Project Sans", system-ui, sans-serif;
}If the fallback causes a visible shift, measure it against the chosen web font before adding size-adjust, ascent-override, descent-override, or line-gap-override. Copied metric values can make the match worse.
If you use Next.js, let the framework own the routine work
The current next/font loaders self-host font files, remove browser requests to Google for Google Fonts, and optimize loading. That is usually a more reliable starting point than hand-building the same pipeline.
import { Inter } from 'next/font/google'
const inter = Inter({
subsets: ['latin'],
variable: '--font-body',
})
export default function RootLayout({ children }) {
return (
<html lang="en" className={inter.variable}>
<body>{children}</body>
</html>
)
}Verify the real load
- Open the page with cache disabled and a slower connection; watch when text appears and whether it moves.
- Request only the weights, styles, axes, subsets, and languages the product actually uses.
- Check Network and Performance tools instead of inferring speed from the file name.
- Keep the fallback usable if the web font never arrives.
Use variable fonts when the range earns its place
A variable font stores a range of variations—commonly weight, width, slant, or optical size—inside one font resource. It can replace several static files and make the type more adaptable, but it is not automatically smaller or better.
- 1Inspect the available axesUse registered properties such as font-weight and font-stretch when they express the axis. Reach for font-variation-settings only when you need a lower-level or custom axis.
- 2Use the range the interface needsIf the product uses only regular and bold, compare the real variable file with two static files. Do not ship unused range for the idea of flexibility.
- 3Test extremes and fallbacksCheck the lightest, heaviest, narrowest, and widest states you expose, plus the browser fallback when an axis or font is unavailable.
Let components adapt to their space
Viewport-based type works for page-level composition. Reusable cards, panels, and modules often care more about the width of their own container. Container query units can help a component respond to the space it actually receives.
.card-grid {
container-type: inline-size;
}
.card-title {
font-size: clamp(1.25rem, 1rem + 2cqi, 2rem);
}
@container (width < 22rem) {
.card-meta {
font-size: 0.875rem;
}
}Test the failures accessibility settings can reveal
Accessibility is not a style preset. It is evidence about whether people can perceive, resize, space, and operate the content. The useful checks are specific enough to reproduce.
A practical typography test pass
- Measure the actual foreground and background colors. WCAG 2.2 AA requires 4.5:1 for normal text and 3:1 for large text, with stated exceptions.
- Resize text to 200% and confirm that content and controls are still available and usable.
- Apply the WCAG 1.4.12 spacing values and look for clipping, overlap, hidden content, or lost functionality.
- Check reflow at a narrow viewport without forcing two-dimensional scrolling for ordinary text.
- Test focus, error, selected, disabled, hover, and loading states—not only the default paragraph.
- Inspect the real implementation rather than guessing contrast or readability from a compressed screenshot.
/* Apply temporarily to test WCAG 1.4.12. */
* {
line-height: 1.5 !important;
letter-spacing: 0.12em !important;
word-spacing: 0.16em !important;
}
p {
margin-block-end: 2em !important;
}Treat dark mode as a new context—not a filter
Do not mechanically invert the palette or apply universal rules such as “always use a lighter weight.” Re-evaluate the contrast, hierarchy, surfaces, states, and perceived weight of the actual typeface in the darker composition.
- Body text feels too bright
- Tune the foreground and background together while preserving required contrast.A softer white may help the composition, but it still needs a measured result.
- Bold type looks heavier
- Test a nearby weight or size before changing the whole system.The effect depends on the face, rendering, color, and surrounding surface.
- Hierarchy collapses
- Rebuild separation with spacing, size, weight, and surface—not just more gray text.Several low-contrast tiers can make the page harder to scan.
A copy-ready starter—with decisions still left for you
This is intentionally small. Replace the font families, colors, and scale with values that fit the project. The useful part is the structure: role-based tokens, bounded display sizes, readable measure, visible focus, and no claim that one snippet solves every interface.
:root {
--font-sans: "Project Sans", system-ui, sans-serif;
--font-display: "Project Display", Georgia, serif;
--text-sm: 0.875rem;
--text-body: 1rem;
--text-lead: 1.25rem;
--text-heading: clamp(1.75rem, 1.35rem + 1.4vw, 2.75rem);
--text-display: clamp(2.5rem, 1.5rem + 4vw, 5.5rem);
--leading-tight: 1.08;
--leading-body: 1.6;
--measure: 68ch;
}
html {
font-family: var(--font-sans);
text-size-adjust: 100%;
}
body {
margin: 0;
font-size: var(--text-body);
line-height: var(--leading-body);
color: #262626;
background: #fafafa;
}
h1, h2, h3 {
margin-block: 0;
font-family: var(--font-display);
line-height: var(--leading-tight);
text-wrap: balance;
}
h1 { font-size: var(--text-display); }
h2 { font-size: var(--text-heading); }
p, li {
max-inline-size: var(--measure);
}
p {
margin-block: 0 1em;
}
:focus-visible {
outline: 0.1875rem solid #005fcc;
outline-offset: 0.1875rem;
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
scroll-behavior: auto !important;
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
}
}Before you ship
- Every text style has a clear role; near-duplicates have been removed or justified.
- Headings, body copy, labels, controls, and metadata hold up with real content.
- The system works at narrow widths, 200% text resize, and user-applied text spacing.
- Contrast is measured in each state and appearance mode.
- The fallback font remains usable and a late font load does not cause a damaging shift.
- Only the font resources and variation axes the page uses are requested.
- The final call is based on the rendered page, not only the tokens or the Figma specimen.
Standards and implementation sources
- MDN: clamp() — syntax, examples, and the accessibility note for fluid font sizes.
- W3C: WCAG 2.2 — contrast, resize text, reflow, and text-spacing requirements.
- web.dev: Best practices for fonts — loading, delivery, rendering, and the tradeoffs between font-display strategies.
- MDN: Variable fonts — registered axes, custom axes, and CSS controls.
- Next.js: Font optimization — current
next/fontsetup for Google and local fonts.
QuestionsAnswers
Questions, answered.
A few practical details before you keep going
Share this resource

Written by
Nikki KippleProduct Designer & Design Instructor
Designer, educator, founder of The Crit. I've spent years teaching interaction design and reviewing hundreds of student portfolios. Good feedback shouldn't require being enrolled in my class — so I built a tool that gives it to everyone. Connect on LinkedIn →
Want a second read on the type in context?
Share one implemented page or screenshot. Your free First Read can identify the typography decision with the biggest effect; the $9 Full Crit opens the wider review and one revision check.