Guides / css
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.
- 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. - 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.
- Layout: with styles resolved, it computes geometry: width, height and position of each box. Elements with
display: nonedon’t even enter this step; withvisibility: hidden, they do and take up space. - Paint: it generates the drawing instructions (background, border, text, shadow) in the right stacking order.
- 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 affects | Examples | Redoes |
|---|---|---|
| Geometry | width, height, margin, padding, top, font-size, display | Layout → Paint → Composite (the most expensive, and it can affect the whole page) |
| Appearance only | color, background, box-shadow, border-radius | Paint → Composite |
| The layer only | transform, 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 writeconst width = box.offsetWidthitems.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.
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:
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 theutilitieslayer beats one inbaseeven 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:
- 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 adiv’sdisplayisn’tblock“by nature”: the browser’s style sheet declares it, anddisplay: revertgoes back to it. - Computed: this is what children inherit. Since
embecomes pixels here, a child inherits the parent’s pixels, not theem. It is the reason for the compounding effect offont-size: 0.8emin nested lists (each level shrinks again), and whyrem(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 likewidth, 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: 20pxused incolor: var(--x)), the declaration is not dropped when the CSS is parsed: it becomes invalid at computed-value time, and the property behaves likeunset(inherits or goes back to its initial value), instead of falling back to the previous declaration. - Variables inherit like text properties. Redefining
--spaceinside.cardchanges everything in there, and that is how themes work: a[data-theme="dark"]onhtmlredefines 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.
- 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
widthand vertical margins, and images align to the text baseline, which leaves that mysterious gap under an<img>(fixed withdisplay: block). - Flex and grid: inside them, the normal flow rules don’t apply. There is no margin collapse,
floatis ignored on children andvertical-aligndoes 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:
| Element | Containing block |
|---|---|
| In normal flow | The nearest block ancestor |
position: absolute | The nearest positioned ancestor (not static) |
position: fixed | The 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-contentis the smallest width without overflowing (the longest word);max-contentis the width without breaking any line;fit-contentsits 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:
Three consequences that look like bugs, but are the algorithm:
flex: 1makes items equal;flex: autodoesn’t.flex: 1means a0%base: all space is “free” and is split evenly.flex: autouses 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-widthisauto(=min-content) in step 2. A long URL or a code block overflows the layout even withflex: 1. The fix ismin-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:
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:
positionother thanstaticwithz-indexother thanauto(andfixed/stickyalways);opacitybelow 1,transform,filter,backdrop-filterormix-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 withcontain-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: 100pxto200pxgenerates every value in between. - Discrete: values with no middle ground, like
displayorvisibility. They switch all at once. Withtransition-behavior: allow-discrete, the switch happens at the right moment of the transition, which lets you animate an element out beforedisplay: none. - Starting state: an element that just appeared has no “before” to interpolate from.
@starting-styledeclares 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:
| Level | Tool | What it reacts to |
|---|---|---|
| Page | Media queries (@media (width >= 768px)) | The window |
| Component | Container queries (@container) | The space of the component’s container (demo) |
| Content | clamp(), minmax(), auto-fill, flex-wrap | The available space, continuously, without breakpoints (demo) |
| User | prefers-color-scheme, prefers-reduced-motion, prefers-contrast | System 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 JavaScript | Today, native | Support | Demo |
|---|---|---|---|
| A tooltip or dropdown library (positioning, closing on outside click) | popover + anchor positioning | Newly (2026) | open |
| A component that measures its own size (ResizeObserver) | Container queries | Widely | open |
| Adding a class to the parent when a child changes | :has() | Widely | open |
| A scroll listener for a fixed header | position: sticky | Widely | open |
| A carousel library | Scroll snap | Widely | open |
| An accordion that closes the others | <details name> | Newly (2024) | open |
| A textarea that grows as you type | field-sizing: content | Newly (2026) | open |
| Generating shades of a color and the dark theme | color-mix() + light-dark() | Newly (2024) | open |
| Computing delays and colors by index | sibling-index() | Newly (2026) | open |
| Animating a gradient’s angle | @property | Newly (2024) | open |
| Balancing heading line breaks by hand | text-wrap: balance | Newly (2024) | open |
| Effects tied to scrolling | animation-timeline | Limited | open |
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:
| Approach | How 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-JS | The build generates unique names per component |
@layer | Native: the order of layers settles conflicts, not specificity |
@scope | Native: limits where a style applies, including a lower bound (@scope (.card) to (.content)); in every browser since December 2025 |
| Shadow DOM | Native: 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
flexorgridbadge 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
| Symptom | What is going on |
|---|---|
A high z-index still stays behind | The element is trapped in a stacking context created by an ancestor |
A fixed modal scrolls with the page | An ancestor with transform, filter or contain became the containing block |
| A flex item overflows its container | min-width: auto prevents the item from shrinking below its content |
| A child’s margin “leaks” out of the parent | Margin collapse; create a BFC (display: flow-root) or use gap |
| A gap under an image | Images are inline and align to the text baseline |
| An animation stutters on phones | It is animating a layout property; switch to transform |
100vh cuts off the screen on phones | The browser bar changes the viewport height; use dvh |
| A CSS variable “doesn’t work” and the property goes back to default | The substituted value is invalid for that property (invalid at computed-value time) |
| Fonts shrinking in nested lists | em 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
requestAnimationFramefits (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
- Inside look at modern web browser, part 3 (Mariko Kosaka): the rendering pipeline illustrated, from parsing to the compositor thread.
- Rendering performance (Paul Lewis, web.dev): the pixel pipeline and the three cost paths.
- CSS Cascade 5: the specification of the cascade, layers and the stages of a value.
- content-visibility (web.dev): how to skip rendering what is off screen.
- How to @scope CSS now that it’s Baseline (Frontend Masters).
- Baseline (web.dev): what each support level means.
- MDN: CSS: the reference for properties, selectors and guides.