Part 3 · 10 chapters · ~75 min

Style

Style resolution finds every rule that matches an element, decides which declaration wins for each property, fills in the rest by inheritance or initial values, and resolves them into computed values. The data structures that make that fast, the bloom filter and the invalidation sets, are also what decide what a single class change costs. This part is selector matching done right to left, the full cascade including layers, the four values of a property, custom properties, invalidation, conditional CSS and fonts.

34

What style resolution has to produce

the question

"The CSSOM exists and the DOM exists. What does the engine actually compute between them?"

For every element, the computed value of every property. Around 400 properties, so for a 2,000-element page that is 800,000 values, most of them inherited or initial, held in a structure Blink calls ComputedStyle and shares between elements that resolve identically. The work is in finding which declarations apply, which is selector matching, and deciding which wins, which is the cascade.

the steps, per element, in document order
  1. Collect. Find every rule whose selector matches this element. This is the expensive step and the one with the clever data structures.
  2. Cascade. For each property with more than one declaration, sort by origin, importance, layer, specificity and order. Keep the winner.
  3. Default. For each property with no winner: inherit from the parent's computed value if the property is inherited, else take the initial value.
  4. Compute. Resolve keywords and relative units into the computed value. Store it.
  5. Share. If a sibling with the same tag, classes, attributes and no :nth-child-style dependencies already resolved, reuse its ComputedStyle rather than recomputing. This is why lists of identical items are cheap.
worked numbers
the render tree, which is the output:

  DOM node   +  ComputedStyle  →  LayoutObject (if display is not none)

  display: none      → no layout object, no box, not in the render tree
  visibility: hidden → layout object exists, box takes space, nothing painted
  ::before/::after  → layout objects with no DOM node
  <head>, <script>  → display: none by the UA sheet, so absent

layout works on this tree, not the DOM. the two are related but not the same shape.
35

Selector matching: right to left

Given an element and a few thousand rules, the engine must find the matching ones fast. The approach: index rules by their rightmost compound selector, test only the rules in the relevant buckets, and for each one walk up the ancestor chain checking the compounds to the left. The walk goes right to left because the rightmost compound is the one that must match the element itself, and most rules fail there in constant time.

the buckets
  1. By id in the rightmost compound: tested only for elements with that id.
  2. By class: tested only for elements carrying that class.
  3. By attribute, by tag, by pseudo-class, by shadow host and slotted: each its own index.
  4. Universal: the rules whose rightmost compound is * or only a pseudo-class, tested against every element. This bucket is the one to keep small.
what makes a selector expensive, precisely
  1. A big rightmost bucket. .sidebar *, [data-x] on the right, :not(.a) on the right. The rightmost test runs for every element in the bucket.
  2. A rightmost test that is itself slow. :nth-child(2n+1) counts siblings. :has() walks descendants. Attribute substring matches compare strings.
  3. Ancestor walks the bloom filter cannot stop. When the ancestor compound uses something not in the filter (attribute selectors, :nth-child), the walk happens.
  4. Depth. A long chain of descendant combinators on a deeply nested element walks far on each candidate.
what does not make a selector expensive
Length. .app .page .card .title is cheap if few elements have class title, because the rightmost test rejects everything else immediately and the bloom filter rejects most of the rest. Cost lives on the right. The "avoid descendant selectors" rule from 2010 was about an engine without a bloom filter; it is mostly superstition now, except for the universal bucket.
SELECTOR MATCHING
right to left, with a bloom filter
swipe the figure sideways, or tap expand for full screen
1/7

When several declarations target the same property on the same element, the cascade decides. It is a sort with a fixed sequence of keys, and knowing the sequence explains every "why isn't my style applying" you will ever debug.

A property with no winning declaration is not undefined. It is either inherited from the parent or set to its initial value, and which one depends on the property, not on you.

Custom properties are ordinary properties with two unusual traits: they inherit, and their value is an unparsed token stream until something uses it. That combination is why they are both the best theming mechanism CSS has and a source of confusing bugs.

Because the stylesheet told the engine that the class could affect a lot of elements, so it marked a lot of elements. The engine does not restyle the document on every change; it uses invalidation sets computed from your selectors to find the minimum set of elements whose style could depend on what changed. Your selectors decide how big that set is.

Pseudo-classes select existing elements by state; pseudo-elements create boxes that have no DOM node. They are matched and styled in the same pass, with a few costs of their own.

Conditional rules are evaluated outside the cascade and change which rules participate in it. Media queries depend on the viewport and the device; container queries depend on an ancestor's size, which means they depend on layout.

Fonts sit between style and paint, and the decision about when to render text with a font that has not arrived is made at style time.

What you seeWhat it meansWhat to do
Recalculate Style, large, right after a class toggleBig invalidation setFind the selector whose right side reaches many elements; make the right side specific
Recalculate Style on every mouse moveA :hover on a container with descendant rulesMove the hover styling to the leaf, or use pointer-events strategically
Recalculate Style before every Layout in an animation frameJS writes a style, reads a layout value, writes againBatch reads then writes; see Part 4
Many small Recalculate Style events during loadStyle injected by JS, or stylesheets arriving lateLink sheets in HTML; avoid runtime CSS injection on the critical path
Recalculate Style with "Elements affected: all"Body or root class change, or a :root custom propertyAccept it for theme toggles; avoid it for per-interaction state