Heading scales with :has()
- Posted
13 July 2025
The :has() CSS selector provides us with the means to apply a typographic hierarchy to heading elements dependent on which heading levels are present.
Justifying the necessary added complexity demands some context.
Reading and typography
Presuming the website you are designing contains text you want readers to read – your primary job as a typographer is to keep out of the way. Reading requires a flow that is easily interrupted.
The right breaks in the text however, are vital for reading: New paragraphs provide us an opportunity to take a mental breath; Headings serve as way-markers, slicing the information into meaningful chunks that help us navigate longer text.
Overly prominent sub-headings in a body of text don’t serve your readers. Unless you are writing about the design of the font, those lovely large characters don’t serve your readers.
Relative typographic contrast
A visual reader encountering a heading is not concerned with which number in the scale the heading lies any more than they are individually identifying the letters of the words. Relative contrast is where it is at. A heading is:
- Distinct from text;
- And lower, higher, or of the same importance as the previous heading.
Well balanced typographic contrast allows readers to discern the hierarchy effortlessly with a minimum of fuss. Constrast between headings ideally balances clarity with cohesion of the whole.
The HTML challenge
Assigning a specific size and style to each of the HTML heading levels is the customary approach. A simple approach and comes with a tradeoff. A fixed heading level to size association forces us to apply strong visual prominence to higher level headings, on the off chance our heading might need to coexist with a set of deeper level headings.
A crude example demonstrating font-size alone to distinguish the ludicrous six levels HTML offers:
Heading level one
Heading level two
Heading level three
Heading level four
Heading level five
Heading level six
Body text
Defining an appropriate contrast between the text and each of the consecutive heading levels nudges the contrast range into the stratosphere. No wonder the default browser styling sizes h6 significantly smaller than body text.
Pragmatically, in the vast majority of projects, dropping h6 from typographic considerations will be inconsequential. How about h5? Fewer levels is always going to be easiest to design. Beware though, adding a level later however, a cause a cascade of rework.
Depth shifting the hierarchy
For flexibility, instead of (or in addition to) assigning the prescribed styles to the HTML heading elements, we create a set of CSS classes that correspond to the designed hierarchy:
.h1 { … }
.h2 { … }
.h3 { … }
.etc { … }
Should a particular web page not use deeper level headings, we can shift the designed scale. For example, should the page we are concerned with only include h1 through to h3 – we can re-assign the sizes by applying the corresponding classes to each heading.
By applying classes belonging to an otherwise deeper level heading, we can be assured we’re sticking with the designed heading scale. A subset of the designed scale preserves the contrasts between each heading level step:
<h1>Page title</h1>
<h2 class="h5">A small level two heading</h2>
<h3 class="h6">A small level two heading</h3>
Page title
Heading level two
Heading level three
Body text
Note I’m not shifting the h1 in this example, instead choosing to keep it consistent between pages as I would expect to be desirable in a multi-page site.
Perils of making the wrong things easy
The flexibility of assigning classes to individual elements brings new ways to break the heading outline in a page. In particular we can create what appear to be headings, but are nothing of the sort:
<div class="h2">Looks exactly like a heading</div>
Element misuse such as this can contribute to a terrible experience for anyone using tools reliant on a page’s accessibility tree. Screen reader users are the usual example.
Maintaining a well structured outline that doesn’t skip levels in challenging enough in a complex website without adding non-heading headings as an option.
Of course it may be perfectly appropriate to make some big text that is not a heading (I’m thinking big text in a promotional banner). Naming classes after HTML elements invites misinterpretation by unwary developers.
Regardless of the class naming convention used, this kind of case-by-case override will likely become the default. You’ll know something it off when you find yourself with a heading size class on every heading element in a page.
Using CSS for presentation
Shifting the hierarchy is the idea I want to pursue. However, if doing so requires micro managing heading sizes with classes in HTML. I say it is not worth the headaches.
We can use CSS to apply the heading sizes based on what headings are present.
Demo
I’ve wired up a little JavaScript to quickly alter which headings are present so we can readily see the applied hierarchy shift accordingly. Try shifting the range input:
Heading level one
Heading level two
Heading level three
Heading level four
Heading level five
Heading level six
Body text
The pertinent CSS
The first layer of the abstraction is a set of variables in place of the classes we created for the previous example. The variable names could be based on the heading element names if doing so didn’t make me itch:
:root {
--f1-size: 3.65em;
--f2-size: 3em;
--f3-size: 2.3em;
--f4-size: 1.85em;
--f5-size: 1.5em;
--f6-size: 1.2em;
}
As mentioned earlier I’m keeping h1 consistent. The other headings all have the least prominent size assigned as a base:
h1 {
font-size: var(--f1-size);
}
h2,h3,h4,h5,h6 {
font-size: var(--f6-size);
}
The promised, the overrides use the :has() selector applied to a parent element:
.hroot {
&:has(h3) h2,
&:has(h4) h3,
&:has(h5) h4,
&:has(h6) h5 {
font-size: var(--f5-size);
}
&:has(h4) h2,
&:has(h5) h3,
&:has(h6) h4 {
font-size: var(--f4-size);
}
&:has(h5) h2,
&:has(h6) h3 {
font-size: var(--f3-size);
}
&:has(h6) h2 {
font-size: var(--f2-size);
}
}
Not much code! Mostly because this example only twiddles font size. A designed implementation would likely want to adjust a fuller set of properties. For example, the space around a heading goes a long way to denote it’s relative importance. Especially if the designed scale is six levels deep!
Future scope and nesting
By thoughtfully placing the hroot class, creating multiple heading scale contexts becomes trivial. These tend to be crop up in the various boxes in a complex website. As the exceptions that are usually responsible for upsetting a plan to simply assign heading sizes directly, these contexts are the chief cause for my interest in this approach.
The missing facet in what I have outlined above is nesting. Dealing with nesting of these contexts is more complicated and no less common in a complex website design. Without the experience of applying this approach in a complete and serious website build I feel less inclined to attempt to advocated for it as a strategy.
I’ve had some experimental success using @scope. Fortunately we can look forward to broad support of @scope relatively soon. As a component of Interop 2025, Firefox ought to catch up in the coming year.
Back to reality
Is this idea useful? I don’t know yet, but I’ll be looking for opportunities to try it in the next new site design I encounter. This site would be an obvious place to start, but hardly a substantial test.