- Pick a value from the same scale as Body and Detail.
- Use fluid type in responsive design with modern CSS
progress(). - Make sure text still grows when the page is zoomed.
- Consider letting a region’s density set the font size.
- Spread Heading levels apart as the screen widens.
Sizing approaches
Section titled “Sizing approaches”Headings can use the same kind of scale as Body and Detail. But Headings are often much larger than other text, so they may need to shrink to fit smaller screens. How best to do that is a matter of opinion.
Here are some of the ways people have adjusted Heading sizes with CSS.
Breakpoints
Section titled “Breakpoints”Responsive design introduced media queries, often called “breakpoints,” which change styles based on the screen width. For example, you might set a large Heading size for desktop and switch to a smaller size below a certain width.
These examples use round numbers to keep things short. Choose your own sizes, ideally from a scale you’ve created.
/* "Desktop" Heading size */h1 { font-size: 3rem; }
/* "Mobile" Heading size */@media (max-width: 480px) { h1 { font-size: 1.5rem; }}This worked when CSS had fewer features and there were only a few mobile screen sizes. Over time, screen sizes multiplied, and choosing a font size for each one became impossible to maintain.
Fluid sizes
Section titled “Fluid sizes”When viewport units arrived, people began setting font size as a fraction of the screen width. At first, breakpoints were still needed to keep the size from getting too large or too small.
/* Font size is 3.33% of the device width, whatever that might be */h1 { font-size: 3.33vw; }
/* At device width under 720px, font size is set at 1.5rem */@media (max-width: 720px) { h1 { font-size: 1.5rem; }}
/* At device width over 1440px, font size is set at 3rem */@media (min-width: 1440px) { h1 { font-size: 3rem; }}Later, the clamp() function let us write the same idea in one line:
h1 { font-size: clamp(1.5rem, 3.33vw, 3rem);}The two approaches differ in when the minimum and maximum take effect. With breakpoints, they apply at screen widths you choose. With clamp(), they apply whenever the middle value goes below the minimum or above the maximum. That makes it hard to control how fast the size changes. You might pick a value that grows more slowly than you expect, so the maximum isn’t reached until a much wider screen. On the other hand, breakpoints make the size “jump” at each width, while clamp() changes smoothly.
Container queries
Section titled “Container queries”Container queries are a newer CSS feature. They work like media queries, but respond to the size of a parent element instead of the screen.
You might be tempted to swap viewport units for container units like cqw. A panel narrower than the screen could then get smaller Headings to fit. However, I recommend that you do not do this.
Headings are ranked against each other. Readers scan a page for the most important Heading, usually by looking for the largest one. If Heading size depends on the width of its container, the most important Heading in a narrow panel might look the same as a less important one elsewhere. Readers can’t tell which content matters most.
So treat all the Headings on a page as a set. If the most important Headings get smaller, the less important ones should shrink too, so their order stays clear. Also make sure the smallest Headings can’t be mistaken for Body text. A Heading should always look more important than the text it introduces, usually by keeping its minimum size larger but could also have other visual treatments.
Since all Headings need to scale together, they need one page-wide value to scale by. That’s why I think viewport units fit better than container units.
Scoped font size
Section titled “Scoped font size”If you’ve read the section with the same name in the font size chapter, you might think scoped density should also be avoided for Headings. After all, I just said Heading size shouldn’t depend on its container.
The difference is control. With scoped density, you still choose the exact Heading size, just indirectly by the area it is placed within. With container queries, the size depends on the container’s width, which you don’t control, so the result is unpredictable.
So you might write the following, where the Heading is much larger than the other text but still comes from the same scale:
[data-density="cozy"] { --fs-heading: var(--fs-5); /* or a fluid size from the scale */ --fs-body: var(--fs-2); --fs-detail: var(--fs-1);}
[data-density="compact"] { --fs-heading: var(--fs-4); /* or a fluid size from the scale */ --fs-body: var(--fs-1); --fs-detail: var(--fs-0);}This approach suits marketing pages and apps, which are split into distinct regions. Editorial pages, like long articles with several Heading levels, usually don’t have separate regions, so there’s nothing to scope. Those pages need Heading levels instead.
Fluid & accessible
Section titled “Fluid & accessible”There’s a catch with viewport units: browsers don’t scale them when you zoom. If your font size uses vw units directly, the text won’t change size when the page is zoomed in or out. This fails WCAG 1.4.4 (Resize Text).
Except for captions and images of text, text can be resized without assistive technology up to 200 percent without loss of content or functionality.
Most browsers zoom to a maximum of 5x. So at maximum zoom, text needs to be at least twice its normal size:
Here, is the font size at 5x zoom for a screen width , and is the font size at normal zoom.
Zooming doesn’t treat every unit the same way. At 5x zoom, every rem and px is drawn five times as large on screen. A vw doesn’t change at all. The screen is the same physical width it was before, so 100vw still covers the same space. Here means the actual width of the screen, which zooming doesn’t change, even though it changes how many CSS pixels fit across it.
This is why using only vw in font-size fails. Nothing in the size grows when you zoom. An accessible fluid size needs a fixed part in rem, so zooming has something to scale.
How do we ensure the fluid size is accessible? With some more math, of course!
Choose the inputs
Section titled “Choose the inputs”To build accessible fluid Headings, we need a few starting values. The first is the smallest Heading size. You should probably pick this from your type scale. For these examples, we’ll use 1.44rem, two steps up the scale from the font size chapter, with a ratio of 1.2.
The other inputs are the screen widths where the Heading stops changing. At the narrow end, the Heading stops shrinking at 320px, the narrowest screen WCAG 1.4.10 (Reflow) asks us to support. But where should the wide end be? In other words, when should the text stop growing?
Body text is easiest to read when each line is about 40–80 characters long. Typographers call line length the measure. I use 60 characters, the middle of that range, so the ideal width for Body text is 60ch. The ch unit is the width of one character (specifically, the 0). More precisely, it’s 60rch, since the root font size most likely matches the Body text.
You can set this measure in CSS, so lines of Body text never grow past it:
p { max-inline-size: 60rch;}That’s the ideal measure for Body text. Is there one for Headings? As far as I can find, no. But I usually set a Heading as a lockup with its Body, so the two read as one unit at the same width. That means the Heading’s widest point can match the Body’s: 60rch. That’s where the Heading stops growing.
Note that this is 60rch, not 60ch. The ch unit is based on the current font size, so on a large Heading it’s wider, and 60ch would end up much wider than the Body below it. The rch unit is based on the root font size, which most likely matches the Body.
From these values, we’ll work out the largest safe and the rest of the fluid behavior. In CSS, we might set the following:
h1 { --fs-min: 1.44rem; --w-min: 320px; --w-max: 60rch;}Safe maximum font size
Section titled “Safe maximum font size”Now we can find the largest font size that still works when zoomed. To do that, we’ll set one rule: the Heading never gets smaller than .
Since is written in rem, at 5x zoom it’s drawn five times as large. Whatever the screen width, the zoomed Heading is never smaller than:
At normal zoom, the Heading is never larger than :
So the rule we started with, , holds at every width as long as:
Rearranged:
This matches the rule of thumb Maxwell Barvian gives in Addressing Accessibility Concerns With Using Fluid Type, which explores this problem in depth: keep the largest size at no more than 2.5 times the smallest. It’s the browser’s 5x maximum zoom divided by the 2x that WCAG asks for.
It’s also the most we can allow. At 5x zoom, a 1600px screen is only 320px wide in CSS pixels, so the zoomed Heading sits at on any screen up to five times . Meanwhile, the unzoomed Heading reaches on any screen wider than . Since 60rch is far narrower than 1600px, there are screens where both happen at once, and there the ratio can’t be more than 2.5.
So the safe maximum is the minimum times 2.5. We’ll call this multiplier the safe factor:
With a 1.44rem minimum, that’s 3.6rem. In CSS, we compute it from the minimum, so changing one updates the other:
h1 { --fs-safe-factor: 2.5; --fs-max: calc(var(--fs-min) * var(--fs-safe-factor));}You can pick from your type scale instead, as long as it’s no more than 2.5 times . CSS can find that step for you. Each step up the scale multiplies the size by the ratio. So the number of steps from to , , is the most that stay within 2.5 times:
In CSS, is --fs-steps. The log() function takes the base as its second argument, and round(down, …) drops the decimal:
h1 { --fs-steps: round(down, log(2.5, var(--fs-ratio)), 1); --fs-safe-factor: pow(var(--fs-ratio), var(--fs-steps));}With a ratio of 1.2, that’s 5 steps, the largest step that stays under 2.5, so the safe factor is about 2.49. Since --fs-min is on the scale, --fs-max is too.
We’ll keep the safe factor at 2.5 in the examples below to keep the code easier to follow.
Use the line with progress()
Section titled “Use the line with progress()”To grow the font size with the screen width, we use the equation of a straight line: . Here, is the slope (how fast the size grows), and is a fixed starting amount. In our terms, it looks like this:
In CSS, the part uses vw, so it follows the width of the screen. The part uses rem, so it’s the fixed part that grows when the page is zoomed.
We now have both ends of the line: at , and at . The slope is how much the font size changes divided by how much the width changes. The starting amount can be found from any known point on the line. We’ll use the smallest size at the narrowest width:
Substituting these into and rearranging gives:
Swapping two parts of the multiplication, using , gives this form:
We want this form because the part is exactly what progress() in CSS calculates. Given a value and a range, progress() returns how far along the range the value is, as a number from 0 to 1. That saves us writing a messier calc(). The first argument is , the second is , and the third is .
Here’s what this looks like in CSS:
h1 { --fs-fluid: calc( progress(100vw, var(--w-min), var(--w-max)) * (var(--fs-max) - var(--fs-min)) + var(--fs-min) );}Earlier, we needed clamp() to stop the size at each end. Here, we don’t. progress() never returns less than 0 or more than 1, so the Heading stays between and on its own. Here are all the steps together:
h1 { /* 1. Define your inputs */ --fs-min: 1.44rem; --w-min: 320px; --w-max: 60rch;
/* 2. Compute the safe maximum font size */ --fs-safe-factor: 2.5; /** or use log() */ --fs-max: calc(var(--fs-min) * var(--fs-safe-factor));
/* 3. Build the fluid typography value */ --fs-fluid: calc( progress(100vw, var(--w-min), var(--w-max)) * (var(--fs-max) - var(--fs-min)) + var(--fs-min) );
/* 4. Use it as the font size */ font-size: var(--fs-fluid);}Heading levels
Section titled “Heading levels”Everything so far sizes one Heading for each region of the page, and that’s how I recommend sizing Headings in an app. Each region sets its own density, and its Heading takes its size from the region it’s in, as in Scoped font size.
Prose is the exception. A long article has no regions to scope. Its structure comes from the Headings themselves: a title, then sections, then subsections. These levels share one region, so their sizes have to come from their rank instead, and they need to work as a set. If you’re setting prose like this, the safe factor works a little differently.
The safe factor tells us how far a Heading may grow: no more than 2.5 times. For one Heading, it’s fine to grow by all of it. For a set of levels, it isn’t a target. If every level grew by the full amount, even the smallest Heading would end up nearly 2.5 times its narrow size. With a ratio of 1.2 and the smallest Heading one step above Body, that’s about 57px on a wide screen. Every section of a long article would open with a title-sized Heading.
Instead, let the levels spread apart as the screen widens. On a narrow screen, there’s little room, so each level is just one step above the next, and the lowest level is one step above Body. That keeps every Heading distinguishable from the text it introduces. As the screen widens, the most important level grows the most, by the steps the safe factor allows, and each level below it grows one step less.
For level of levels, where level is the most important:
The first sets the size on a narrow screen. The second sets how many steps that level grows, so its safe factor is . Since is never more than , every level stays within 2.5 times its smallest size, and every level stays safe when zoomed. All levels still grow by the same progress(), so they scale together.
With a ratio of 1.2, is 5. For three levels, with Body on step 1:
| Level | Narrow | Wide | ||
|---|---|---|---|---|
h1 |
4 | 5 | 33.2px |
82.6px |
h2 |
3 | 4 | 27.6px |
57.3px |
h3 |
2 | 3 | 23px |
39.8px |
On a narrow screen, the levels are one step apart. On a wide one, they’re two steps apart. The hierarchy gets more pronounced when there’s room for it.
In CSS, each level only needs its number. The rest comes from the formulas above:
:root { --fs-steps: round(down, log(2.5, var(--fs-ratio)), 1); --heading-levels: 3;}
h1 { --heading-level: 1; }h2 { --heading-level: 2; }h3 { --heading-level: 3; }
:where(h1, h2, h3) { --heading-step: calc(var(--body-step) + var(--heading-levels) + 1 - var(--heading-level)); --fs-min: calc(var(--fs-base) * pow(var(--fs-ratio), var(--heading-step))); --fs-safe-factor: pow(var(--fs-ratio), var(--fs-steps) + 1 - var(--heading-level));}The number of levels can’t be more than . Past that, the lowest level would have to shrink as the screen widens. With a ratio of 1.2, that’s six levels, one for each Heading element. A larger ratio allows fewer: 1.25 gives , enough for five.
Since the levels sit on several steps, their tracking should change with their size too. See the note at the end of By font family in the letter spacing chapter.