The Code Codex
Theme
← Guides

CSS under the hood

How the browser turns CSS into pixels: the rendering pipeline, how selectors are matched, the cascade and the stages of a value, formatting contexts, the flexbox and grid algorithms, painting order, compositing and performance, and what modern CSS can do on its own.

31 Jan 2025· 19 min· 16 sections

In this guide · 16 sections

Why understand CSS under the hood

Writing CSS got easy: any AI assistant generates a flexbox layout, a card with a shadow or a dark theme in seconds. What is still hard, and where you really save time, is understanding why something doesn’t work: the z-index: 9999 that stays behind everything, the flex item that overflows the screen, the animation that stutters on phones, the margin that disappears.

Those problems aren’t solved with more code; they are solved by knowing what the browser does with the CSS. This guide is about that: the mechanisms behind it and the capabilities they unlock. The syntax of each property is left to MDN and to the CSS Playground, where you can see each feature working.

From HTML to pixels

The browser turns HTML and CSS into an image in a sequence of steps, and repeats part of them every time something changes.

HTML

DOM

element tree

CSS

CSSOM

rule tree

Style

final value of every

property of every node

Layout

position and size

of every box

Paint

draw lists

per layer

Composite

combines the layers

on the GPU

HTML

DOM

element tree

CSS

CSSOM

rule tree

Style

final value of every

property of every node

Layout

position and size

of every box

Paint

draw lists

per layer

Composite

combines the layers

on the GPU

  1. Parsing: HTML becomes the DOM and CSS becomes the CSSOM. CSS is render-blocking: the browser paints nothing until it has all of the page’s CSS, so it doesn’t show an unstyled version and then “jump”. That is why large CSS in the <head> delays the first screen.
  2. Style: for each element, the browser finds which rules apply and resolves the cascade until it reaches a value for every property, including the ones you never wrote.
  3. Layout: with styles resolved, it computes geometry: width, height and position of each box. Elements with display: none don’t even enter this step; with visibility: hidden, they do and take up space.
  4. Paint: it generates the drawing instructions (background, border, text, shadow) in the right stacking order.
  5. Composite: the page is split into layers, rasterized into tiles and combined by the GPU on a separate thread, the compositor thread.
The cost of each change

When a property changes (on hover, a new class, from JavaScript), the browser redoes the pipeline from the step that property affects:

The change affectsExamplesRedoes
Geometrywidth, height, margin, padding, top, font-size, displayLayout → Paint → Composite (the most expensive, and it can affect the whole page)
Appearance onlycolor, background, box-shadow, border-radiusPaint → Composite
The layer onlytransform, opacity (and filter, in several browsers)Composite only, on the compositor thread

Hence the golden rule: animate transform and opacity. They run on the compositor thread and stay smooth even if the main thread is busy with JavaScript. Animating left or width redoes layout on every frame, and at 60 frames per second you have about 16 ms for everything.

Layout thrashing

The browser batches changes and recomputes layout once per frame. But if JavaScript reads a measurement (offsetHeight, getBoundingClientRect()) right after writing a style, the browser is forced to recompute on the spot. In a loop, that becomes dozens of layouts per frame:

// bad: alternates writes and reads (one layout per iteration)
items.forEach((el) => { el.style.width = `${box.offsetWidth}px` })
// good: read once, then write
const width = box.offsetWidth
items.forEach((el) => { el.style.width = `${width}px` })

How a selector is matched

Selectors are patterns over the DOM tree. The surprise is the direction: the browser matches them right to left. For nav a, it doesn’t find the nav and walk down; it takes every a on the page and walks up its ancestors looking for a nav.

body

header

main

nav

a ✓

has a nav above

a ✓

article

p

a ✗

walks up to body

without finding nav

body

header

main

nav

a ✓

has a nav above

a ✓

article

p

a ✗

walks up to body

without finding nav

Starting from the right (the key selector) quickly rules out most elements: only the a elements are candidates. The relationships between parts of a selector describe paths in that tree:

  • space (nav a): any ancestor above;
  • > (ul > li): only the immediate parent;
  • + and ~ (h2 + p, h2 ~ p): following siblings, at the same level;
  • :has(): the exception that looks down or sideways, starting from the element.

In practice, with today’s engines, selectors are rarely the performance bottleneck; what matters is how many elements are affected when something changes. Toggling a class on body invalidates the style of the whole page; toggling it on a list item, only that part.

The cascade and the stages of a value

When several declarations compete for the same property of an element, the cascade picks one, comparing these criteria in order and only moving on to the next when there is a tie:

Origin and importance

browser, user, site

!important inverts

Context

shadow DOM

Element-attached

style attribute

Layers

@layer

Specificity

IDs, classes,

elements

Order

the last one wins

Origin and importance

browser, user, site

!important inverts

Context

shadow DOM

Element-attached

style attribute

Layers

@layer

Specificity

IDs, classes,

elements

Order

the last one wins

Two details that explain a lot of fights with CSS:

  • Specificity is compared in columns (IDs, classes, elements), not added up. A single class beats any number of elements; a single ID beats any number of classes. :where() has zero specificity, which makes it ideal for base styles that are easy to override.
  • Layers come before specificity. With @layer reset, base, components, utilities;, a rule in the utilities layer beats one in base even with a much weaker selector. Styles outside any layer beat every layer. It is the native tool to stop inflating selectors and scattering !important.
The six stages of a value

Picking the winning declaration is only the beginning. The specification defines the stages every value goes through until it becomes pixels, and each one explains a behavior:

Declared

every declaration

that applies

Cascaded

the winner

Specified

inherited or initial

value if none

Computed

relative units

resolved: 2em → 32px

Used

depends on layout:

50% → 412px

Actual

rounded for

the device

Declared

every declaration

that applies

Cascaded

the winner

Specified

inherited or initial

value if none

Computed

relative units

resolved: 2em → 32px

Used

depends on layout:

50% → 412px

Actual

rounded for

the device

  • Specified: every property of every element has a value, even without a declaration. If the property is inherited (the text ones: color, font-*, line-height), it comes from the parent; if it isn’t (margin, border, background), the spec’s initial value is used. That is why a div’s display isn’t block “by nature”: the browser’s style sheet declares it, and display: revert goes back to it.
  • Computed: this is what children inherit. Since em becomes pixels here, a child inherits the parent’s pixels, not the em. It is the reason for the compounding effect of font-size: 0.8em in nested lists (each level shrinks again), and why rem (relative to the root) doesn’t have that problem.
  • Used: width percentages depend on layout, so they are only resolved at this stage. That is why getComputedStyle(), despite its name, returns the resolved value: for geometry properties like width, it is the used value, in pixels; for most others, the computed one.
CSS variables under the hood

Custom properties are substituted at the computed value stage: the browser replaces var(--x) with the variable’s text and only then checks whether the result is valid. Two consequences:

  • If the result is invalid (e.g. --x: 20px used in color: var(--x)), the declaration is not dropped when the CSS is parsed: it becomes invalid at computed-value time, and the property behaves like unset (inherits or goes back to its initial value), instead of falling back to the previous declaration.
  • Variables inherit like text properties. Redefining --space inside .card changes everything in there, and that is how themes work: a [data-theme="dark"] on html redefines half a dozen variables and the whole page changes.

Since the browser doesn’t know the type of a regular variable (to it, it’s just text), it can’t interpolate between two values, so variables don’t animate. Registering a type with @property fixes that (demo).

Formatting contexts: who decides the layout

There is no single layout algorithm in CSS: there are several, and the parent’s display decides which one applies to its children. In practice, display has two parts: how the box behaves on the outside (block or inline) and which algorithm it uses on the inside.

parent's display

block / inline

normal flow

flex

flexbox algorithm

grid

grid algorithm

table

table algorithm

Block formatting

boxes stacked

vertically

Inline formatting

text and inline boxes

in line boxes

parent's display

block / inline

normal flow

flex

flexbox algorithm

grid

grid algorithm

table

table algorithm

Block formatting

boxes stacked

vertically

Inline formatting

text and inline boxes

in line boxes

  • Block formatting: boxes stacked top to bottom, each taking the available width. This is where margin collapse happens: vertical margins of neighboring blocks don’t add up, the larger one wins.
  • Inline formatting: content flows in line boxes, like text. Inline elements ignore width and vertical margins, and images align to the text baseline, which leaves that mysterious gap under an <img> (fixed with display: block).
  • Flex and grid: inside them, the normal flow rules don’t apply. There is no margin collapse, float is ignored on children and vertical-align does nothing.
Block formatting context (BFC)

A BFC is an “isolated region” of layout: what happens inside doesn’t leak out. Children’s margins don’t collapse with the parent’s, and floats are contained. Among others, these create a BFC: display: flow-root (which exists just for that), overflow other than visible, flex and grid items, position: absolute and display: inline-block. When a margin “leaks” out of the parent or a float escapes its container, the answer is almost always to create a BFC.

Containing block

Percentages and positioning are relative to the containing block, which isn’t always the parent:

ElementContaining block
In normal flowThe nearest block ancestor
position: absoluteThe nearest positioned ancestor (not static)
position: fixedThe window, unless an ancestor has transform, filter or contain: then it is that ancestor

The last row explains a classic bug: a fixed modal that suddenly scrolls with the page because someone added a transform to an ancestor.

How a box’s size is calculated

All sizing in CSS is a negotiation between two kinds of size:

  • Extrinsic: imposed from outside (width: 300px, 50%, or the parent’s space).
  • Intrinsic: comes from the content. min-content is the smallest width without overflowing (the longest word); max-content is the width without breaking any line; fit-content sits between the two, capped by the available space.

A block with width: auto takes all the available space (extrinsic); a flex item or an inline-block element starts at its content size (intrinsic). Knowing which of the two worlds a box lives in explains most “why doesn’t this take the full width?” questions.

The flexbox algorithm

Flexbox resolves sizes in steps:

1 · Base size

flex-basis, or the

content size

2 · Limits

applies min-width

and max-width

3 · Free space

container minus

the sum of bases

Space left over:

split by

flex-grow

Not enough space:

taken by flex-shrink

× base size

1 · Base size

flex-basis, or the

content size

2 · Limits

applies min-width

and max-width

3 · Free space

container minus

the sum of bases

Space left over:

split by

flex-grow

Not enough space:

taken by flex-shrink

× base size

Three consequences that look like bugs, but are the algorithm:

  • flex: 1 makes items equal; flex: auto doesn’t. flex: 1 means a 0% base: all space is “free” and is split evenly. flex: auto uses the content size as the base and splits only the leftover, so items with more text end up larger.
  • Shrinking is weighted by size. When space runs out, an item with a 400px base shrinks twice as much as one with 200px, even with the same flex-shrink.
  • Flex items don’t shrink below their content, because their default min-width is auto (= min-content) in step 2. A long URL or a code block overflows the layout even with flex: 1. The fix is min-width: 0.
The grid algorithm

Grid sizes the tracks (rows and columns) first and only then places items in them: fixed-size tracks first, then the ones that depend on content (auto, min-content, minmax()), and finally the remaining space is split among the fr tracks. That is why grid is great for structure (the layout rules) and flexbox for content (the items rule).

Some capabilities that come out of this model:

  • repeat(auto-fill, minmax(240px, 1fr)): the grid computes how many columns of at least 240px fit, which means a responsive layout without any media query (demo).
  • grid-template-areas: the layout described as a drawing, which can be redrawn entirely at a breakpoint.
  • Subgrid: an item reuses the parent grid’s tracks, aligning the title, text and button of neighboring cards even with content of different sizes.

Painting order and stacking

After layout, the browser needs to decide what goes in front. Within each stacking context, painting always follows the same order: the browser paints this stack from bottom to top, and whatever is painted later ends up in front:

↑ front

7 · Positioned with positive z-index

6 · Positioned with z-index auto or 0

5 · Inline content: text

4 · Floats

3 · Blocks in normal flow

2 · Children with negative z-index

1 · The context's own background and border

↓ back

↑ front

7 · Positioned with positive z-index

6 · Positioned with z-index auto or 0

5 · Inline content: text

4 · Floats

3 · Blocks in normal flow

2 · Children with negative z-index

1 · The context's own background and border

↓ back

The crucial point: a stacking context is painted whole, as a unit, inside its parent context. A child’s z-index only orders elements inside its own context. If main has z-index: 1 and header has z-index: 10, nothing inside main gets in front of header, not even with z-index: 9999.

Among others, these create a stacking context:

  • position other than static with z-index other than auto (and fixed/sticky always);
  • opacity below 1, transform, filter, backdrop-filter or mix-blend-mode;
  • flex or grid items with z-index;
  • isolation: isolate, which exists precisely to create a context on purpose.

That is why a simple opacity animation or a transform on an ancestor can “break” a dropdown: it created a new context without anyone noticing.

The top layer

A <dialog> opened with showModal() and elements with the popover attribute go to the top layer, a layer above all of the page’s stacking contexts, regardless of where they are in the HTML. It is the native solution for modals, menus and tooltips, and it ends the z-index race (demo).

Compositing and performance

At the compositing step, some elements get their own layer on the GPU: those with transform or opacity animations, will-change: transform, videos, canvas, fixed elements in some cases. Moving a layer is cheap; creating too many layers isn’t, because each one takes GPU memory. will-change is a hint for the browser to get ready, and should be used sparingly, not on everything.

CSS also lets you tell the browser what it can skip:

  • contain: layout paint: promises that nothing inside the element affects layout or painting outside it. A change in there doesn’t force the browser to recompute the whole page.
  • content-visibility: auto: the browser skips style, layout and paint of the content of off-screen sections until they get close to the viewport. In a web.dev example, a long page’s initial rendering time dropped from 232 ms to 30 ms. Use it with contain-intrinsic-size: auto 500px, so the section reserves an estimated height and the scrollbar doesn’t jump.
.article-section {
content-visibility: auto;
contain-intrinsic-size: auto 600px;
}

Animations under the hood

Animating means interpolating: computing the in-between values between a start and an end, frame by frame. That is only possible when the browser knows the value’s type:

  • Interpolable: numbers, lengths, colors, transforms. width: 100px to 200px generates every value in between.
  • Discrete: values with no middle ground, like display or visibility. They switch all at once. With transition-behavior: allow-discrete, the switch happens at the right moment of the transition, which lets you animate an element out before display: none.
  • Starting state: an element that just appeared has no “before” to interpolate from. @starting-style declares that starting state and enables entry animations with CSS alone.

transition reacts to a value change (two states); animation with @keyframes follows its own timeline (many states, repetition). And the timeline doesn’t have to be the clock: with animation-timeline, it can be the page’s scroll (demo).

Respect prefers-reduced-motion: some people get dizzy from motion, and the operating system lets them ask for less animation.

Responsive from the inside out

The old responsive model was the page decides: media queries look at the window width and redraw everything. Today’s CSS lets each part adapt on its own:

LevelToolWhat it reacts to
PageMedia queries (@media (width >= 768px))The window
ComponentContainer queries (@container)The space of the component’s container (demo)
Contentclamp(), minmax(), auto-fill, flex-wrapThe available space, continuously, without breakpoints (demo)
Userprefers-color-scheme, prefers-reduced-motion, prefers-contrastSystem preferences

Ideally, solve as much as possible at the content level (intrinsic layouts), use container queries for components and keep media queries for the page structure. Logical properties (margin-inline, padding-block) complete the picture: the layout also works in right-to-left languages.

Units evolved with this model too: dvh follows the real screen height on phones (as the browser bar appears and disappears), cqi is relative to the container width, and oklch() defines colors by perceived lightness, which makes palettes and contrast predictable (demo).

What CSS does on its own today

Much of what used to require JavaScript or a library is now native behavior. That means less code and, above all, accessible and performant behavior for free (keyboard, screen readers, compositor). Each row opens the demo in the CSS Playground:

Before, with JavaScriptToday, nativeSupportDemo
A tooltip or dropdown library (positioning, closing on outside click)popover + anchor positioningNewly (2026)open
A component that measures its own size (ResizeObserver)Container queriesWidelyopen
Adding a class to the parent when a child changes:has()Widelyopen
A scroll listener for a fixed headerposition: stickyWidelyopen
A carousel libraryScroll snapWidelyopen
An accordion that closes the others<details name>Newly (2024)open
A textarea that grows as you typefield-sizing: contentNewly (2026)open
Generating shades of a color and the dark themecolor-mix() + light-dark()Newly (2024)open
Computing delays and colors by indexsibling-index()Newly (2026)open
Animating a gradient’s angle@propertyNewly (2024)open
Balancing heading line breaks by handtext-wrap: balanceNewly (2024)open
Effects tied to scrollinganimation-timelineLimitedopen

The support column follows web.dev’s Baseline: widely = in every major browser for more than two and a half years; newly = reached all of them more recently (the year is when it became complete); limited = some browser is still missing. To use new features safely, @supports (property: value) { … } applies a block only where the browser understands it, and everything else keeps working.

CSS at scale

CSS is global: any rule can reach any element, and the cascade settles the conflicts. Every organization strategy is a way to control that reach:

ApproachHow it controls reach
BEM (.card__title--highlight)Naming convention: low, flat specificity, every class has an owner
Utilities (Tailwind, as on this site)There is almost no cascade: each class does one thing and lives in the HTML
CSS Modules, CSS-in-JSThe build generates unique names per component
@layerNative: the order of layers settles conflicts, not specificity
@scopeNative: limits where a style applies, including a lower bound (@scope (.card) to (.content)); in every browser since December 2025
Shadow DOMNative: full isolation for web components

Debugging the way the browser sees it

  • Elements → Computed: the final value of each property and, when expanded, the declaration that won the cascade and the ones that lost.
  • Flex and grid overlays: the flex or grid badge in the Elements panel draws lines, tracks and gaps.
  • Rendering → Paint flashing: flashes green whatever is repainted. If the whole page flashes during an animation, something is triggering paint or layout.
  • Layers: shows which elements got their own layer and why.
  • Performance: record an interaction and look for long Recalculate Style and Layout blocks, a sign of layout thrashing.
  • A temporary outline on everything shows which box is overflowing the width: * { outline: 1px solid rgb(255 0 0 / 0.4); }.

Symptoms and causes

SymptomWhat is going on
A high z-index still stays behindThe element is trapped in a stacking context created by an ancestor
A fixed modal scrolls with the pageAn ancestor with transform, filter or contain became the containing block
A flex item overflows its containermin-width: auto prevents the item from shrinking below its content
A child’s margin “leaks” out of the parentMargin collapse; create a BFC (display: flow-root) or use gap
A gap under an imageImages are inline and align to the text baseline
An animation stutters on phonesIt is animating a layout property; switch to transform
100vh cuts off the screen on phonesThe browser bar changes the viewport height; use dvh
A CSS variable “doesn’t work” and the property goes back to defaultThe substituted value is invalid for that property (invalid at computed-value time)
Fonts shrinking in nested listsem is inherited already converted to pixels; use rem

Videos

  • In The Loop (Jake Archibald, JSConf.Asia 2018, 35 min): how the browser’s event loop interleaves JavaScript, style, layout and paint every frame. It explains why some style changes “don’t animate” and where requestAnimationFrame fits (the video embedded at the top).
  • Learn CSS Grid the easy way (Kevin Powell): grid in practice, using the browser inspector to see the tracks.

References

1/16 · Why understand CSS under the hood