Understanding CSS hierarchy-matching

Posted on

To get a feel for how CSS-selectors should be written, you need to understand how browsers match them to the related markup. The following tries to give a top-level description that might not be entirely accurate but should still give you a good enough idea of how CSS and HTML get combined during rendering.

Note: Writing this, I realized that this is probably not how it actually works, as using regular expressions to match selectors and hierarchies would obviate the need for repeated identification-cycles. Nevertheless, thinking of the process this way helps writing simpler selectors, so the model remains valid.

Finding hierarchies

Consider a site with the following markup:

<html>
  <head>
    <!-- omitted -->
  </head>
 
  <body>
    <header>
      <h1>Site title</h1>
    </header>
 
    <nav>
      <ol>
        <li>
          <a href="#">Link</a>
        </li>
        <li>
          <a href="#">Link</a>
        </li>
      </ol>
    </nav>
 
    <section>
      <article>
        <header>
          <h2>Article title</h2>
 
          <p>Date</p>
        </header>
 
        <p>Content</p>
 
        <p>Content with <a href="#">link</a></p>
 
        <ul>
          <li>
            <a href="#">Link</a>
          </li>
          <li>
            <a href="#">Link</a>
          </li>
        </ul>
      </article>
    </section>
  </body>
</html>
<html>
  <head>
    <!-- omitted -->
  </head>
 
  <body>
    <header>
      <h1>Site title</h1>
    </header>
 
    <nav>
      <ol>
        <li>
          <a href="#">Link</a>
        </li>
        <li>
          <a href="#">Link</a>
        </li>
      </ol>
    </nav>
 
    <section>
      <article>
        <header>
          <h2>Article title</h2>
 
          <p>Date</p>
        </header>
 
        <p>Content</p>
 
        <p>Content with <a href="#">link</a></p>
 
        <ul>
          <li>
            <a href="#">Link</a>
          </li>
          <li>
            <a href="#">Link</a>
          </li>
        </ul>
      </article>
    </section>
  </body>
</html>

When discarding the content and chaining all tags together according to their nesting, the path to each tag can be extracted:

TagHierarchy
htmlhtml
bodyhtml body
headerhtml body header
h1html body header h1
navhtml body nav
olhtml body nav ol
lihtml body nav ol li
ahtml body nav ol li a
lihtml body nav ol li
ahtml body nav ol li a
sectionhtml body section
articlehtml body section article
headerhtml body section article header
h2html body section article header h2
phtml body section article header p
phtml body section article p
phtml body section article p
ahtml body section article p a
ulhtml body section article ul
lihtml body section article ul li
ahtml body section article ul li a
lihtml body section article ul li
ahtml body section article ul li a

Note that some tags share the same hierarchy.

While each hierarchy could be used as a (bad) CSS-selector, think of them as an attribute we add to each tag that we now match against selectors.

Matching simple selectors

Selectors are matched to markup from right to left. Single-tag selectors are simplest and instruct the browser to ignore all elements whose hierarchy does not end in the exact tag given.

For example, matching the above table against the selector li would trim it down to just four elements:

TagHierarchy
lihtml body nav ol →li←
lihtml body nav ol →li←
lihtml body section article ul →li←
lihtml body section article ul →li←

The browser only had to filter the initial table a single time to find these elements and can now apply the styles set with the selector.

Matching multi-level selectors

As soon as we chain selectors the browser has to iterate over intermediate results multiple times, potentially filtering them with every step.

When applying a rule such as ul > li, the browser again starts at the rightmost tag and filters the hierarchy-table to end up with the same result as before:

TagHierarchy
lihtml body nav ol →li←
lihtml body nav ol →li←
lihtml body section article ul →li←
lihtml body section article ul →li←

This list now gets filtered again to identify all li-tags nested directly below a ul-tag:

TagHierarchy
lihtml body section article →ul > li←
lihtml body section article →ul > li←

The more levels there are in a selector, the more times each resulting list needs to be iterated over again.

While extending a selector will often result in a condensed list, unnecessary extensions generate wasted filtering-cycles. In the above example, adding html > body > section > article > in front of the selector would force the browser to filter the result four more times without altering the list before it could assert a match.

Identification loops

While every level adds a new iteration over a filtered list of potentially-matching tags, some selectors require a more complex analysis of a single element’s hierarchy. While the child-selector (>) only requires a check of the direct parent tag, a descendant-selector (space) might force the browser to walk up the entire hierarchy trying to find a match.

For example, nav a first filters out all elements whose hierarchy does not end in an a-tag, resulting in the following list:

TagHierarchy
ahtml body nav ol li →a←
ahtml body nav ol li →a←
ahtml body section article p →a←
ahtml body section article ul li →a←
ahtml body section article ul li →a←

When adding nav to the selector, the browser has to execute a more detailed analysis of each element’s hierarchy. For the first element, it has to go through the following steps:

IterationHierarchyResult
1html body nav ol →li a←nav not found, continue…
2html body nav →ol← li →a←nav not found, continue…
3html body →nav← ol li →a←nav found, match

The browser can stop walking up this element’s hierarchy as soon as a match is found and continue with the next element. In contrast to that, the last element from the initial list gets matched against nav a as follows:

IterationHierarchyResult
1html body section article ul →li a←nav not found, continue…
2html body section article →ul← li →a←nav not found, continue…
3html body section →article← ul li →a←nav not found, continue…
4html body →section← article ul li →a←nav not found, continue…
5html →body← section article ul li →a←nav not found, continue…
6→html← body section article ul li →a←nav not found, no match

For elements that only match the end of a selector but not its beginning, the browser has to walk up the entire hierarchy before it can assess that the element definitely does not match.

In real-world examples, pages usually contain a much higher number of elements with deeper and more complex hierarchies. Since the matching-process has to be repeated for every element remaining after each filtering-step, it is best to keep nesting of markup to a minimum and use selectors that match or fail as quickly as possible.

You can read more recommendations on doing the latter in my post on writing high-performance CSS.

Debug
none
Grid overlay