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.
What style resolution has to produce
"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.
- Collect. Find every rule whose selector matches this element. This is the expensive step and the one with the clever data structures.
- Cascade. For each property with more than one declaration, sort by origin, importance, layer, specificity and order. Keep the winner.
- Default. For each property with no winner: inherit from the parent's computed value if the property is inherited, else take the initial value.
- Compute. Resolve keywords and relative units into the computed value. Store it.
- 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.
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.
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.
- By id in the rightmost compound: tested only for elements with that id.
- By class: tested only for elements carrying that class.
- By attribute, by tag, by pseudo-class, by shadow host and slotted: each its own index.
- Universal: the rules whose rightmost compound is
*or only a pseudo-class, tested against every element. This bucket is the one to keep small.
- 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. - A rightmost test that is itself slow.
:nth-child(2n+1)counts siblings.:has()walks descendants. Attribute substring matches compare strings. - Ancestor walks the bloom filter cannot stop. When the ancestor compound uses something not in the filter (attribute selectors, :nth-child), the walk happens.
- Depth. A long chain of descendant combinators on a deeply nested element walks far on each candidate.
.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.The cascade, in full
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.
- Origin and importance. From lowest to highest: user-agent normal, user normal, author normal, animations, author !important, user !important, user-agent !important, transitions. !important reverses the origin order, which is why some UA styles cannot be overridden.
- Context. Declarations from an outer tree (light DOM) beat declarations from a shadow tree for the same element in normal declarations; the reverse for !important. This is how a page can override a component's default and a component can insist on something.
- Element-attached styles. The
styleattribute beats any selector. Not a specificity of (1,0,0,0); a separate step above specificity. - Cascade layers.
@layer reset, base, components, utilities;declares the order. Later layers win. Unlayered styles beat all layers. For !important, the order flips: earlier layers win. - Specificity. A triple (ids, classes + attributes + pseudo-classes, elements + pseudo-elements). Compared left to right, no carrying.
:where()is zero.:is(),:not(),:has()take the most specific argument.*and combinators are zero. - Scope proximity. With
@scope, a declaration from a nearer scope root wins. - Order of appearance. Later wins. Across stylesheets, in the order the sheets appear in the document, which your bundler controls and may change.
Inheritance, initial values, and the four values
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.
- Inherited: color, font-*, line-height, letter-spacing, text-align, visibility, cursor, direction, list-style. Roughly: things about text and reading, which you want to set once on a container.
- Not inherited: margin, padding, border, width, height, display, position, background, overflow. Roughly: things about boxes, where inheriting would be absurd.
- Overridable:
inheritforces inheritance on anything;initialforces the spec default;unsetpicks whichever is natural;revertrolls back to the previous origin's value;revert-layerrolls back one layer. - all: unset resets every property at once. A component reset in one line, with the side effect of also unsetting
display.
- Specified. The cascade's winner, or the inherited/initial fallback.
- Computed. Relative units and keywords resolved without layout. What children inherit. What
getComputedStylereturns for most properties. - Used. After layout: auto and percentages resolved. What
getComputedStylereturns for width, height and a few others, which is why reading those forces layout. - Actual. After device constraints: snapped, clipped, substituted.
font-size: 1.2em compound: 1.2, 1.44, 1.728. Nested line-height: 1.2em is the same trap; unitless line-height: 1.2 inherits the factor instead. This is the technical content of "use rem" and "unitless line-height".Custom properties and the cascade
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.
- They cascade like any property. Specificity, layers, order all apply.
--brandon.theme-darkbeats--brandon:rootfor elements inside.theme-dark. - They inherit by default. Set on
:root, visible everywhere.@propertycan turn inheritance off and declare a type and initial value. - The value is substituted at computed-value time.
var(--gap)in a declaration is replaced by the custom property's computed value on that element. If the result is invalid for the property, the declaration becomesunset, not ignored. This is "invalid at computed-value time" and it bites: a bad token in a variable silently wipes the property rather than falling back to an earlier declaration. - Fallbacks.
var(--gap, 8px)applies when--gapis not defined, not when it is defined badly. - They cross shadow boundaries. Which is the entire styling API of Web Components.
- Changing one triggers invalidation of every element that uses it, plus their descendants if it is inherited. A variable on
:rootthat every element reads is a full restyle when it changes. That is a theme switch and it is expensive by construction.
@property --angle {
syntax: '<angle>';
inherits: false;
initial-value: 0deg;
}
a typed custom property can be animated and transitioned, because the engine knows how to interpolate it.
an untyped one cannot: it is just tokens.Style invalidation: what a change recomputes
"I added one class to one element. Why did the profile show 40 ms of Recalculate Style?"
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.
- At stylesheet parse time, for every class, id, attribute and tag that appears in a non-rightmost position, the engine records what it could affect to its right: which descendant features, which sibling features, whether it affects the whole subtree.
- When a class is added or removed, the engine looks up that class's set and marks matching descendants (and siblings if the set says so) for recalc. Elements outside the set are never visited.
- When an attribute changes, same with the attribute set.
[data-state]in selectors builds a set for that attribute. - When the pseudo-class state changes (hover, focus, checked), same again.
:hoveron a large container with.container:hover .itemmarks every item on every mouse move across the boundary. - When DOM structure changes, inserted or removed nodes get full recalc, and siblings may be invalidated for
:nth-child,+and~dependencies.
.x *: whole subtree.body.theme .anything: every.anythingin the document when the body class flips.:nth-child,:nth-last-child,:first-childon lists: sibling sets.:has(): the subject is to the left of what changes, so a change deep in a subtree can invalidate ancestors. The engine handles it, but it is the one selector where cost flows upward.
Pseudo-classes, pseudo-elements and generated content
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.
- Structural (:first-child, :nth-child, :last-of-type). Need sibling counts. Cheap to match once; expensive to invalidate when siblings change.
- State (:hover, :focus, :active, :checked, :disabled). Flags on the element. Hover is special: the engine tracks the hovered chain and re-matches on every pointer move that changes it.
- :focus-visible and :focus-within. Focus-within walks up from the focused element and marks ancestors; cheap because the chain is short.
- :is(), :where(), :not(). Forgiving selector lists. :where() contributes zero specificity, which is the standard way to write a low-specificity base rule.
- :has(). The parent selector. Matches when a descendant (or sibling, with combinators) matches. The engine caches results per element and invalidates upward. Fine in moderation; a document full of
:has()on large subtrees is where it shows in profiles. - :defined, :host, :host-context(), ::slotted(), ::part(). The Web Components set.
- ::before and ::after. Generated boxes, children of the element in the render tree only. They need
contentto exist. Not in the DOM, so not in the accessibility tree unless the content is text. - ::marker, ::placeholder, ::selection, ::first-line, ::first-letter. Each with a restricted set of applicable properties, because they are not full boxes.
- ::backdrop for dialogs and fullscreen; ::view-transition-* for view transitions; ::highlight() for the CSS highlight API.
Media queries, container queries and conditional CSS
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.
- Evaluated once, re-evaluated on change. Viewport resize, orientation, prefers-color-scheme toggling. On change, every rule inside a matching block becomes applicable or not, and the affected elements are invalidated.
- On a link element, a non-matching media attribute lowers fetch priority and removes the sheet from the render-blocking set. The sheet is still fetched.
- Range syntax.
@media (400px <= width <= 800px). Supported everywhere that matters now. - User preference queries. prefers-reduced-motion, prefers-contrast, prefers-color-scheme, forced-colors. These are accessibility features; honour them.
- A container is an element with
container-type: inline-size(or size). Declaring it establishes size containment on that axis: the element's size cannot depend on its children's. That is the price, and it is what makes the feature possible without infinite loops. @container (min-width: 400px)applies to descendants of a container at least 400px wide. Evaluated during layout, because the container's size is a layout result.- So style depends on layout. The engine runs layout for the container, evaluates the query, restyles the descendants, lays them out. One extra pass per container level. Usually cheap; deeply nested containers can make it not.
- Container units. cqw, cqh, cqi, cqb. Percent of the container, not the viewport.
- Style queries.
@container style(--variant: compact). Conditional on a custom property's value, no layout dependency.
Fonts and style: what font-display is really choosing
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.
- Discovery. The font URL is in an
@font-facerule in CSS. The preload scanner cannot see it. The fetch starts only when style resolution finds an element that uses the family and a character in the font'sunicode-range. Preload the critical one. - The block period. While the font loads, text using it is rendered invisible (a fallback font takes space but is not painted) for up to the block period. Then the swap period: fallback painted, swapped when the font arrives. Then failure: fallback stays.
- font-display sets the periods.
block: 3 s block, infinite swap.swap: tiny block, infinite swap.fallback: 100 ms block, 3 s swap.optional: 100 ms block, no swap: if it is not there almost immediately, this page view does not use it. - The layout shift. Fallback and web font have different metrics, so the swap reflows text.
size-adjust,ascent-overrideanddescent-overrideon a fallback@font-facemake the metrics match, which removes the shift. - Subsetting and unicode-range. Split the font by script; only the ranges that appear on the page are fetched.
swap with a metric-matched fallback, so words are readable at once and do not jump when the font lands. Branding on a hero: optional, because a flash of the wrong typeface there is worse than the system font. Never block on a critical path.Reading style cost in the profile
| What you see | What it means | What to do |
|---|---|---|
| Recalculate Style, large, right after a class toggle | Big invalidation set | Find the selector whose right side reaches many elements; make the right side specific |
| Recalculate Style on every mouse move | A :hover on a container with descendant rules | Move the hover styling to the leaf, or use pointer-events strategically |
| Recalculate Style before every Layout in an animation frame | JS writes a style, reads a layout value, writes again | Batch reads then writes; see Part 4 |
| Many small Recalculate Style events during load | Style injected by JS, or stylesheets arriving late | Link 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 property | Accept it for theme toggles; avoid it for per-interaction state |
- Route
/style/invalidation-set: two buttons toggle a class on the same element. One is matched by.open > .panel; the other by.open *. Record each click and compare "Elements affected". - Route
/style/hover-container: move the mouse across the grid with the Performance panel recording. Count Recalculate Style events per second, then enable the "leaf hover" variant and repeat. - Route
/style/theme-toggle: flip the body class. Note the recalc size and that it is the cost of the design, not a bug. - Route
/style/font-display: throttle to Slow 3G, reload under each font-display value, and watch when text appears and whether it shifts.