DEV Community

137Foundry
137Foundry

Posted on

How to Set Up a Type Scale With CSS Custom Properties in an Afternoon

You don't need a design tokens pipeline or a build step to get a real type scale into a project. You need a ratio, a set of custom properties, and about an afternoon. Here's the version that gets you from nothing to a working, documented scale without overengineering it on day one.

This isn't the version you'd build if you were setting up token tooling for a large multi-brand product with a dedicated design systems team. It's the version for the much more common situation: an existing project with scattered font-size declarations, a developer who has an afternoon free, and a real need to stop the bleeding before it gets worse. You can always layer more tooling on top of this foundation later; you can't easily retrofit a foundation onto a project that never had one.

Step 1: Pick a ratio and a base size

Start with a base font size, usually 1rem (which respects the user's browser default, typically 16px), and a ratio to multiply up and down from. This is the same idea as a modular scale in print typography, where every size in a layout is derived from one base measurement and one multiplier rather than picked independently. A ratio between 1.2 and 1.333 is a safe starting range for body text and headings that need to coexist on the same page without the jump between them feeling extreme. Anything much larger starts to feel like a poster rather than a page.

You don't need to overthink this step. Pick a ratio, build the scale, and look at it. If two adjacent sizes feel too close together or too far apart, adjust the ratio, not the individual values, or you'll end up with an inconsistent scale that doesn't actually follow a pattern anymore.

Step 2: Define the scale as custom properties

Put the scale at the root level so every component can reference it without recalculating anything:

:root {
  --step-base: 1rem;
  --ratio: 1.25;
  --step-1: calc(var(--step-base) * var(--ratio));
  --step-2: calc(var(--step-1) * var(--ratio));
  --step-3: calc(var(--step-2) * var(--ratio));
  --step-down-1: calc(var(--step-base) / var(--ratio));
}
Enter fullscreen mode Exit fullscreen mode

This gets you a scale that's easy to adjust globally, since changing --ratio or --step-base recalculates everything downstream. It's also self-documenting in a way that scattered hardcoded font-size declarations never are. Anyone opening the stylesheet can see the relationship between sizes at a glance.

Add a second step down if your project uses small print or fine legal text somewhere, rather than reaching for an arbitrary pixel value the day you need it:

--step-down-2: calc(var(--step-down-1) / var(--ratio));
Enter fullscreen mode Exit fullscreen mode

Five to seven steps total, two or three below the base and three or four above it, covers the large majority of real projects. If you find yourself wanting an eighth or ninth step, it's usually a sign that two existing steps are close enough to merge rather than a sign you need to keep extending the scale.

notebook open pages handwritten notes
Photo by Max Grakov on Pexels

Step 3: Make the top and bottom of the scale fluid

Static custom properties are a fine start, but the sizes that matter most, your largest heading and maybe your body copy, benefit from scaling smoothly with viewport width instead of jumping at breakpoints. Wrap the calculation in clamp() once you've settled on the static values, using them as your min and max:

--step-3: clamp(var(--step-2), 2.5vw + 1rem, var(--step-2) * 1.1);
Enter fullscreen mode Exit fullscreen mode

You don't have to make every step fluid. Body text and small captions are usually fine as static values; it's the large display sizes that benefit most from smooth interpolation, since that's where a breakpoint jump is most visually obvious.

Step 4: Apply unitless line-height

Whatever line-height you set, use a unitless number rather than a fixed pixel or rem value. A unitless value scales proportionally with the computed font-size of whatever element it's applied to, so it keeps working correctly even as your fluid sizes change across viewport widths.

body {
  line-height: 1.5;
}
h1, h2, h3 {
  line-height: 1.2;
}
Enter fullscreen mode Exit fullscreen mode

Step 4.5: Don't forget measure, the width of the line

Font-size and line-height get most of the attention, but line length matters just as much for readability and it's easy to leave unconstrained. Set a max-width on your body text containers, expressed in ch units so it scales naturally with the font it's paired with, rather than leaving paragraphs free to stretch edge-to-edge on a wide monitor:

p {
  max-width: 65ch;
}
Enter fullscreen mode Exit fullscreen mode

A paragraph that runs the full width of a large screen is measurably harder to track line to line than one constrained to a reasonable measure, even if the font-size itself is perfectly fine.

Step 5: Document what you just built

This is the step that gets skipped and causes the most pain later. Write down, in the same file or a linked README, what each step is for: --step-1 is used for subheadings, --step-down-1 is for captions and metadata, and so on. The full CSS custom properties specification is on MDN if you want the exact inheritance and fallback rules, which matter once you start nesting scoped overrides inside components.

Step 6: Sanity-check contrast and accessibility before you call it done

A scale isn't finished just because it renders. Check your smallest step against the Web Accessibility Initiative's guidance on text contrast and resizing, particularly if that smallest step is used for anything a user actually needs to read rather than purely decorative text. It's a five-minute check that catches a real class of bugs before they ship.

What you'll have by the end of the afternoon

A root-level scale defined in custom properties, a couple of fluid steps using clamp() for the sizes that need it most, unitless line-heights that won't drift as the scale changes, and a short written explanation of what each step is for. That's enough to hand off to another developer without a meeting, and it's the foundation you'd extend later into a full design tokens system if the project grows into one.

Realistically, budget the afternoon like this: half an hour to inventory the sizes currently in use, an hour to pick the ratio and write the custom properties, half an hour to convert the top one or two sizes to clamp(), twenty minutes on line-height and measure, and the rest of the time on writing down what you built so the next person doesn't have to reverse-engineer it. None of these steps are individually hard. What makes the difference is doing all of them in the same sitting instead of stopping after step two and never circling back to the documentation, which is how most undocumented scales get started in the first place.

What you won't have, and don't need yet, is a build pipeline that syncs these values with a design tool, a versioned token package published for other projects to consume, or a theming layer for dark mode and light mode variants. Those are real, valuable additions for a team that's outgrown the basics, but they're also exactly the kind of upfront complexity that stops a lot of type scale projects from ever getting started. Ship the plain CSS version first. It's a genuine improvement over scattered hardcoded values, and it gives you a stable foundation to layer more tooling onto later, once you actually know you need it rather than guessing in advance.

For the fuller picture of how the ratio choice, fluid scaling, and production edge cases fit together, see our longer guide: How to Design a Responsive Typography Scale That Actually Holds Up. And if your team wants a second set of eyes on a scale before it ships, that's a conversation 137Foundry has regularly with teams in exactly this spot.

Top comments (0)