HTML & CSS NotesRohit’s interview study guide
Chapter 10

Animation & Rendering Performance

Transitions, keyframe animations and transforms; what the browser does to draw a frame and what makes it redo work; animating cheaply and accessibly; entry animations and view transitions; and how CSS and fonts affect page load.

10 topics
Parts marked Advanced are extra depth. Skip them on a first read or a quick revision.

#Transitions

Added

A transition animates a property from its old value to its new value when that value changes. Something changes the computed style (a class is added, :hover starts matching, JavaScript sets a style), and instead of jumping, the browser interpolates over the duration you gave. You declare it on the element in its normal state: transition: <property> <duration> <timing-function> <delay>, with several comma-separated transitions if you need them.

Mental model: a transition is a rubber band between two computed values. It only exists while a change is happening, it needs both a "before" and an "after" value, and if the value changes again mid-way, the band is re-stretched from wherever it currently is. That's the difference from a keyframe animation, which runs on its own without any change.

Transitions are as old as CSS3 and Baseline everywhere. In a test (and in DevTools) a running transition is a real object: el.getAnimations() returns CSSTransition objects you can pause and seek, which is how every value below was measured.

A width and colour transition
CSS
.chip {
  width: 100px;
  height: 36px;
  padding: 0 10px;
  font: 13px/36px sans-serif;
  background: #fecaca;
  transition: width 1000ms linear, background-color 1000ms ease;
}
.chip.on,
.chip:hover {
  width: 300px;
  background: #bfdbfe;
}
HTML
<div class="chip">Hover me</div>
Resultlive, in your browser
What’s happening
  1. At rest the chip is 100px wide and rgb(254, 202, 202). The transition declaration does nothing yet; it only says how future changes to width and background-color should be animated.
  2. Adding .on (or hovering, in the live result) changes both computed values at once. Instead of jumping, the browser creates two CSSTransition objects, one per property, each from the old value to the new one.
  3. Paused at 500 ms of 1000 ms, the linear width transition is exactly half way: 200px.
  4. The colour uses ease, which starts fast and slows down, so at half the time it's already about 80% of the way: rgb(203, 216, 244), much closer to the target blue than to the starting pink. Colours are interpolated channel by channel.
  5. When the transitions finish they're removed (getAnimations() is empty again) and the element simply has its new computed values, 300px and rgb(191, 219, 254). Remove .on and two new transitions run back.
AdvancedTiming functions, measured

What each timing function does at 25% and 50%

The timing function maps elapsed time to progress. These are the widths of a 100 → 300 px transition, paused at 250 ms and 500 ms of 1000 ms.

CSS
.t { width: 100px; height: 8px; margin: 3px 0; background: #c7d2fe; }
.t.on { width: 300px; }
.ease { transition: width 1000ms ease; }
.ease-in { transition: width 1000ms ease-in; }
.ease-out { transition: width 1000ms ease-out; }
.ease-in-out { transition: width 1000ms ease-in-out; }
.steps { transition: width 1000ms steps(4); }
.overshoot { transition: width 1000ms cubic-bezier(0.3, 1.6, 0.6, 1); }
.custom { transition: width 1000ms linear(0, 0.9 50%, 1); }
HTML
<div class="t ease"></div><div class="t ease-in"></div><div class="t ease-out"></div><div class="t ease-in-out"></div>
<div class="t steps"></div><div class="t overshoot"></div><div class="t custom"></div>
Resultlive, in your browser
What’s happening
  1. ease (the default) is already at 260.469px at half time: it front-loads the movement. ease-out is similar (236.922px), and both feel responsive because the element reacts immediately.
  2. ease-in is the opposite, only 163.062px at half time: slow start, fast finish. It suits things leaving the screen and feels sluggish for things that should respond to a click.
  3. ease-in-out is symmetrical, so at exactly half time it's exactly half way, 200px, but at 25% it's only 125.828px.
  4. steps(4) jumps in four equal steps: at 25% of the time it has just taken its first jump to 150px, at 50% its second to 200px. Good for sprite-sheet animations and ticking clocks.
  5. cubic-bezier(0.3, 1.6, 0.6, 1) has a y value above 1, so it overshoots: 321.609px at half time, past the 300 px target, before settling back. That's how "springy" effects are made without JavaScript.
  6. linear(0, 0.9 50%, 1) (Baseline 2023) draws the curve point by point: progress 0.9 at 50% of the time. Measured 280px at 500 ms (100 + 0.9 × 200) and 190px at 250 ms, half way to that point. With enough points it can approximate bounces and springs.
AdvancedInterrupting a transition

Changing your mind half way

If the value changes back while a transition is running (the pointer leaves before the hover transition finished), the new transition starts from the current value, not from the end, and its duration is shortened in proportion. The spec calls this the reversing shortening factor.

CSS
.r { width: 100px; height: 20px; background: #a7f3d0; transition: width 1000ms linear; }
.r.on { width: 300px; }
HTML
<div class="r"></div>
Resultlive, in your browser
What’s happening
  1. The forward transition is paused half way, at 200px.
  2. Removing .on sets a new target of 100px. The browser cancels the forward transition and creates a new one whose keyframes go 200px → 100px: it starts where the element is.
  3. Its duration is 500 ms, not 1000: it only has half the distance to cover. Without that rule, a quick hover-out would crawl back at half speed.
  4. With a non-linear timing function the shortening uses the eased progress, so durations come out less round (with ease paused at 300 ms, Chrome gave the way back 513 ms).
AdvancedWhat doesn't transition

Changes that don't produce a transition

CSS
.auto { height: 0; overflow: hidden; background: #fde68a; transition: height 500ms; }
.auto.on { height: auto; }
.fade { opacity: 0.4; transition: opacity 1000ms; width: 120px; height: 20px; background: #bfdbfe; }
HTML
<div class="auto"><p>Some content</p></div>
Resultlive, in your browser
What’s happening
  1. height: 0 → auto has no transition (0 animations); the box jumps to its content height. auto isn't a number the browser can interpolate. Workarounds: transition grid-template-rows: 0fr → 1fr, animate max-height to a guessed value, or use interpolate-size: allow-keywords / calc-size(), which are Chrome-only as of October 2026.
  2. A .fade element inserted by JavaScript gets no transition for its first style, even though it has a transition: there was no "before" value to transition from. That's why an element appearing with display or from insertBefore doesn't fade in. The fix is Entry animations with @starting-style and transition-behavior.
  3. Once it exists, a later change (opacity to 1) transitions normally.
  4. Discrete properties such as display don't interpolate either; by default they just flip. transition-behavior: allow-discrete (same topic) lets them take part.
AdvancedGotchas: transition: all and events

#Keyframe animations

Added

A keyframe animation is a named timeline: @keyframes lists the values a property should have at points from 0% (from) to 100% (to), and the animation property attaches it to an element with a duration, timing function, delay, iteration count, direction, fill mode and play state. It starts as soon as the element has an animation-name that matches a @keyframes rule; nothing has to change.

Mental model: keyframes are a film reel, the animation property is the projector settings: how long one pass takes, how many passes, whether every other pass runs backwards (alternate), and whether the first or last frame stays on screen before and after (fill-mode). The element's own styles are what you see when the projector is off.

A bar that fills and empties
CSS
@keyframes fill {
  from { width: 20px; background-color: #fca5a5; }
  to { width: 220px; background-color: #86efac; }
}
.bar {
  height: 24px;
  border-radius: 6px;
  animation: fill 2000ms linear infinite alternate;
}
.track {
  width: 220px;
  outline: 1px dashed #94a3b8;
}
HTML
<div class="track"><div class="bar"></div></div>
Resultlive, in your browser
What’s happening
  1. animation: fill 2000ms linear infinite alternate creates a CSSAnimation named fill as soon as the page renders. Its timing reads back as a 2000 ms duration, infinite iterations, alternate direction, linear easing.
  2. The first pass runs forwards. Sampled every 500 ms it goes 20px → 70px → 120px → 170px → 220px: 50 px per 500 ms, because linear maps time straight to progress.
  3. alternate makes every second pass run backwards, so from 2000 to 4000 ms it goes 220px → 170px → 120px → 70px → 20px and then starts filling again. Without alternate, it would jump from 220px back to 20px at the end of each pass.
  4. Colours animate alongside: at 1000 ms (half way) the background is rgb(193, 202, 169), the channel-by-channel midpoint of #fca5a5 (252, 165, 165) and #86efac (134, 239, 172).
  5. In the live result it loops forever; the test paused it and set currentTime to sample each point, which is the same thing DevTools' Animations panel does when you scrub.
AdvancedDelay and fill mode

What shows before and after the animation

animation-fill-mode decides what the element looks like outside the active period: backwards applies the first keyframe during the delay, forwards keeps the last keyframe after the end, both does both, and none (the default) shows the element's own styles.

CSS
@keyframes grow { from { width: 100px; } to { width: 300px; } }
.g { width: 50px; height: 14px; margin: 4px 0; background: #c4b5fd; }
.both { animation: grow 1000ms linear 500ms 2 alternate both; }
.none { animation: grow 1000ms linear 500ms 1; }
HTML
<div class="g both"></div>
<div class="g none"></div>
Resultlive, in your browser
What’s happening
  1. .both is grow 1000ms linear 500ms 2 alternate both: a 500 ms delay, two passes of 1000 ms, the second backwards. Its timing reads back as delay 500, activeDuration 2000, endTime 2500.
  2. At 0 ms it's still in the delay, but backwards filling (part of both) already shows the first keyframe: 100px, not its own 50px. Without it you'd see a jump when the delay ends.
  3. At 1000 ms (500 ms into pass 1) it's 200px; at 1500 ms pass 1 ends at 300px; at 2000 ms the reversed pass 2 is back at 200px.
  4. At 3000 ms the animation is over. Because two alternate passes end on the first keyframe, forwards filling holds 100px.
  5. .none has no fill mode: during the delay and after the end it shows its own width: 50px, and only during the active pass (1000 ms → 200px) does the animation apply. "My animation snaps back at the end" is almost always a missing forwards.
AdvancedMulti-step keyframes, steps() and the cascade

More than two keyframes, and who wins

CSS
@keyframes pulse {
  0%, 100% { opacity: 1; }
  50% { opacity: 0.2; animation-timing-function: linear; }
}
@keyframes tick { from { width: 100px; } to { width: 300px; } }
.p { animation: pulse 2s ease-in-out infinite; width: 120px; height: 14px; background: #93c5fd; }
.k { height: 10px; margin: 4px 0; background: #fca5a5; animation: tick 1000ms steps(4) infinite; }
.k.inline { width: 999px; }
.k.important { width: 33px !important; }
HTML
<div class="p"></div>
<div class="k" id="k1"></div>
<div class="k" id="k2" style="width: 999px"></div>
<div class="k important" id="k3"></div>
Resultlive, in your browser
What’s happening
  1. 0%, 100% { opacity: 1 } is shorthand for two keyframes; getKeyframes() shows three: offsets 0, 0.5 and 1.
  2. animation-timing-function inside a keyframe sets the easing from that keyframe to the next: the 50% keyframe's easing is linear, the others keep the element's ease-in-out.
  3. steps(4) on the tick animation means 30% of the time shows the first step, 150px (25% of the way), not a smooth 160px.
  4. Animations sit high in the cascade: animated values override all normal declarations, even an inline style="width: 999px" (the second bar measures 150px too).
  5. But !important declarations beat animations: the third bar stays 33px. If an animation "does nothing", look for an !important on the same property.
AdvancedControlling animations from JavaScript

Pausing, restarting and the Web Animations API

CSS animations and transitions are exposed as Animation objects, the same type that element.animate() creates (Web Animations, Baseline since 2020). The demo below's test runs each line of this:

JavaScript
const [anim] = el.getAnimations();   // CSSAnimation for each animation-name
anim.pause();                         // same effect as animation-play-state: paused
anim.currentTime = 500;               // seek
anim.play();

// Restarting a CSS animation: remove it, force a style update, add it back
el.classList.remove("run");
void el.offsetWidth;                  // without this line it's the same animation
el.classList.add("run");

// A one-off animation without any CSS
el.animate([{ opacity: 0 }, { opacity: 1 }], { duration: 300, easing: "ease-out" });
CSS
@keyframes spin { to { rotate: 1turn; } }
.run { animation: spin 2000ms linear infinite; }
.s { width: 40px; height: 40px; background: #fdba74; }
.paused { animation-play-state: paused; }
HTML
<div class="s run" id="s1"></div>
<div class="s run paused" id="s2"></div>
Resultlive, in your browser
What’s happening
  1. getAnimations() returns the running CSSAnimation; pausing and setting currentTime = 500 (a quarter of 2000 ms) gives rotate: 90deg.
  2. Removing and re-adding the class in the same task doesn't restart anything: the browser never computed a style without run, so it's still the same animation object.
  3. Reading offsetWidth between the two forces a style (and layout) update; the browser sees the animation removed, then a new one added, and starts it from 0 (getAnimations()[0] is a different object). This is the well-known restart trick, and a deliberate forced reflow.
  4. animation-play-state: paused shows up as playState: "paused".
  5. el.animate() creates a plain Animation with the same controls; half way through a linear 0 → 1 fade, opacity is 0.5. Use it for animations whose values are computed in JavaScript, like the FLIP technique in Animating cheaply.
Why does the animation snap back?
CSS
@keyframes slide-in { from { translate: -100% 0; } to { translate: 0 0; } }
.panel { translate: -100% 0; animation: slide-in 300ms ease-out; }

The panel slides in and then jumps back off-screen. Why, and what are two fixes?

Show answer

After 300 ms the animation ends and stops applying; the element's own style, translate: -100% 0, takes over again.

CSS
@keyframes slide-in { from { translate: -100% 0; } to { translate: 0 0; } }
.panel { width: 120px; height: 20px; background: #bbf7d0; translate: -100% 0; animation: slide-in 300ms ease-out; }
.panel.forwards { animation-fill-mode: forwards; }
.panel.fixed { translate: 0 0; }
HTML
<div class="panel" id="p1"></div>
<div class="panel forwards" id="p2"></div>
<div class="panel fixed" id="p3"></div>
Resultlive, in your browser
What’s happening
  1. While the animation runs, its keyframes override the element's translate. When it finishes, with no fill mode, it no longer applies: the first panel reads translate: -100% again, off to the left.
  2. Fix one: animation-fill-mode: forwards keeps the last keyframe after the end: the second panel stays at 0% (the animated value, interpolated from -100%). Simple, but the animation keeps overriding the property forever, so a later translate change in CSS won't show.
  3. Fix two (usually better): make the element's own style the end state (translate: 0 0) and let the animation only describe the journey from -100%. The third panel ends at 0px with nothing left applying.

#Transforms

Added

transform moves, rotates, scales or skews an element's rendering without changing layout. Layout happens first, then the transform is applied to the element's painted box, like moving a sticker that's already been placed. Neighbours don't move, the parent doesn't grow, and offsetLeft and offsetTop still report the original layout position; only getBoundingClientRect() reports where the box appears on screen.

Mental model: layout decides where the element's slot is; a transform moves the picture of the element relative to its slot. That's why transforms are cheap to animate (no layout) and why they're the right tool for hover effects, slides and drags. The functions are translate(), rotate(), scale(), skew() and matrix(), plus 3D versions; they apply right to left about the transform-origin (the centre by default).

A transform doesn't move its neighbours; a margin does
CSS
.row { display: flex; gap: 10px; margin-bottom: 8px; }
.row div { width: 50px; height: 50px; background: #cbd5e1; font: 12px/50px sans-serif; text-align: center; }
.row .moved-by-transform { transform: translateX(100px); background: #93c5fd; }
.row .moved-by-margin { margin-left: 100px; background: #fca5a5; }
.lift { transition: transform 200ms ease-out; }
.lift:hover { transform: translateY(-4px) scale(1.1); }
HTML
<div class="row" id="r1"><div class="moved-by-transform">T</div><div class="next">next</div></div>
<div class="row" id="r2"><div class="moved-by-margin">M</div><div class="next">next</div></div>
<div class="row"><div class="lift">hover</div></div>
Resultlive, in your browser
What’s happening
  1. Both rows start the same: a 50 px box, a 10 px gap, a "next" box. The first box of row 1 has transform: translateX(100px); the first box of row 2 has margin-left: 100px.
  2. The transformed box appears at x = 112 (the 12 px body margin plus 100), but its offsetLeft is still 12: its layout slot didn't move.
  3. Its neighbour stays at 72 (12 + 50 + 10) both in layout and on screen. The transformed box now overlaps it instead of pushing it.
  4. The margin version really moved in layout (offsetLeft and the rect are both 112), and its neighbour was pushed to 172. Changing margin means the browser has to redo layout for the row; changing a transform doesn't.
  5. The computed value of any transform is a matrix: translateX(100px) reads back as matrix(1, 0, 0, 1, 100, 0), where the last two numbers are the x and y translation. The third row lifts and grows on hover in the live result (a test can't hover).
AdvancedOrder matters: transforms apply right to left

translate then rotate is not rotate then translate

CSS
.stage { position: relative; height: 170px; }
.sq { position: absolute; top: 0; left: 0; width: 50px; height: 50px; }
.a { transform: translateX(100px) rotate(90deg); background: #93c5fd; }
.b { transform: rotate(90deg) translateX(100px); background: #fca5a5; }
HTML
<div class="stage"><div class="sq a">A</div><div class="sq b">B</div></div>
Resultlive, in your browser
What’s happening
  1. Read a transform list from right to left as operations on the element (or left to right as moves of the coordinate system). A: rotate 90° in place, then move 100 px right. It ends up 100 px to the right of where it started, rotated.
  2. B: move 100 px along the x axis after that axis has been rotated 90°, which now points down. It ends up 100 px below its start ((0, 100) relative to the stage).
  3. The computed matrix for A, matrix(0, 1, -1, 0, 100, 0), is the 90° rotation (0, 1, -1, 0) plus a translation of 100, 0. When two transforms need to combine (a hover scale on top of a positioning translate), write them in one list in the order you mean, or use the individual properties below.
AdvancedIndividual transform properties and transform-origin

translate, rotate and scale as separate properties

Since 2022 (Baseline; widely available since February 2025) translate, rotate and scale are separate properties. They're always applied in a fixed order (translate, then rotate, then scale, then transform), and each can be transitioned or animated on its own, so a hover scale no longer overwrites a positioning translate.

CSS
.box { width: 50px; height: 50px; background: #a7f3d0; }
.ind { translate: 10px 0; rotate: 90deg; scale: 2; }
.stage { position: relative; height: 120px; margin-left: 100px; }
.stage .o { position: absolute; width: 100px; height: 50px; rotate: 90deg; background: #fde68a; }
.stage .tl { transform-origin: top left; }
HTML
<div style="height: 110px"><div class="box ind"></div></div>
<div class="stage"><div class="o center"></div></div>
<div class="stage"><div class="o tl"></div></div>
Resultlive, in your browser
What’s happening
  1. .ind sets translate, rotate and scale but no transform; transform stays none, and each property reads back on its own (10px, 90deg, 2).
  2. scale: 2 doubles the drawn size of the 50 px box to 100 × 100. As always with transforms, its layout box is still 50 × 50.
  3. The yellow 100 × 50 boxes are rotated 90°. About the default centre origin, the rotated box is 50 wide and 100 tall and sits 25 px right and 25 px above its layout slot: it turned around its middle.
  4. With transform-origin: top left it turns around its top-left corner instead, so it swings to the left of its slot: x = -50, y = 0. Getting the origin right is most of the work in rotation effects (a door opening, a dial needle).
AdvancedGotcha: transforms change containing blocks

#The rendering pipeline: style, layout, paint and composite

Added

Every time something changes, the browser runs some or all of a fixed sequence of steps to turn the DOM and CSS into pixels. Knowing the steps is what makes performance advice make sense, because each kind of change starts the pipeline at a different step, and the later it starts, the cheaper it is.

JavaScript DOM / style change Style computed values Layout sizes, positions Paint draw, rasterise Composite layers → frame width, margin, font-size color, background transform, opacity (on its own layer)
Which steps a change re-runs. Every change recalculates style; only geometry changes need layout.
  1. Style: match selectors and compute every element's values (the cascade, inheritance, var(), calc() where it can). Output: computed styles.
  2. Layout (also "reflow"): work out each box's size and position from those styles, the viewport and the content (line breaks, flex and grid algorithms). Output: geometry.
  3. Paint: turn boxes into drawing commands (backgrounds, borders, text, shadows) and rasterise them into pixels, often in tiles and on other threads.
  4. Composite: combine the painted layers, applying transforms and opacity, into the final frame on the GPU.

Mental model: an assembly line. A change to a box's geometry has to go back to the layout station and everything after it; a colour change only needs repainting; a transform on its own layer only needs the final assembly. At 60 frames per second the whole line has about 16.7 ms per frame (1000 ÷ 60) for your JavaScript plus all of these steps; 120 Hz screens halve that.

Which changes reach layout
CSS
.row { display: flex; gap: 8px; margin-bottom: 8px; align-items: flex-start; }
.row .box { width: 60px; height: 40px; background: #cbd5e1; }
.row .after { width: 60px; height: 40px; background: #e2e8f0; }
.row:hover .w, .w.changed { width: 140px; }
.row:hover .bg, .bg.changed { background: #f97316; }
.row:hover .tf, .tf.changed { transform: translateX(80px); }
HTML
<div class="row"><div class="box w"></div><div class="after" id="a-w"></div></div>
<div class="row"><div class="box bg"></div><div class="after" id="a-bg"></div></div>
<div class="row"><div class="box tf"></div><div class="after" id="a-tf"></div></div>
Resultlive, in your browser
What’s happening
  1. Three identical rows: a box and its neighbour at offsetLeft 80 (12 px margin + 60 px box + 8 px gap). Each row changes one property of the first box (hover a row in the live result to see it).
  2. Row 1 changes width to 140px. The neighbour's layout position becomes 160: the browser had to redo layout for the row, then repaint and composite.
  3. Row 2 changes background. The colour is now rgb(249, 115, 22), but no geometry changed: the neighbour is still at 80, the box at x = 12. Layout can be skipped; only paint (and composite) run.
  4. Row 3 changes transform. The box is drawn at x = 92, but its offsetLeft is still 12 and the neighbour still 80: no layout. If the browser has the box on its own compositor layer (it usually promotes elements with running transform animations), only composite runs.
  5. What this test can prove is the layout part: which changes move other boxes. Which steps actually ran, and how long each took, isn't visible from inside the page; that's what the Performance panel in DevTools shows (purple Layout, green Paint, and so on), and it wasn't measured here.
AdvancedWhat happens inside one frame

The order of work in a frame

Inside one frame, Chromium runs roughly: input events → requestAnimationFrame callbacks → style → layout → ResizeObserver callbacks (which can cause another style and layout pass) → paint → composite. Two practical consequences:

  • Code in requestAnimationFrame runs just before style and layout, so it's the right place for visual updates: they're applied in the same frame. At the start of the callback the previous frame's layout is still clean, so reading geometry before writing anything is free; reading after writing forces layout, as anywhere else.
  • The compositor runs on its own thread. A transform or opacity animation that has been handed to it keeps running smoothly even while the main thread is stuck in JavaScript; a width or left animation can't, because every frame needs main-thread layout.

None of this timing can be observed by the tests on this page: headless Chrome under a virtual time budget, as used by the checker, doesn't produce real animation frames. To see it, record a trace in the DevTools Performance panel.

AdvancedWhich properties trigger what

A rule of thumb, not a law

ChangeTypically re-runsExamples
Geometrystyle, layout, paint, compositewidth, height, margin, padding, border-width, top/left, font-size, display, adding or removing elements, changing text
Appearance onlystyle, paint, compositecolor, background, box-shadow, border-radius, visibility, outline
Compositor-onlystyle, compositetransform, opacity (and often filter) on an element with its own layer

The exact cost depends on the engine and the situation (how big the affected area is, whether the element already has its own layer). Treat the table as a guide to what to measure first, and confirm in the Performance panel.

#Reflow and repaint: what makes the browser redo work

Added

Reflow (layout) is recalculating geometry; repaint is redrawing pixels. Normally the browser is lazy about both: when you change styles it just marks things "dirty" and does the work once, at the next frame. But if your JavaScript asks for a layout value (offsetHeight, getBoundingClientRect(), scrollTop…) while the layout is dirty, the browser can't answer from stale data, so it runs style and layout immediately, synchronously, in the middle of your script. That's a forced synchronous layout.

One forced layout is fine. The problem is layout thrashing: a loop that writes a style, reads a layout value, writes, reads, and so on, forcing a full layout on every iteration instead of once per frame.

Mental model: the browser is a cook who batches orders. Changing a style is adding an order to the ticket; reading offsetHeight is asking "is my dish ready?", which forces the cook to stop and cook everything on the ticket right now. Ask after every single order and nothing gets batched.

Every read after a write sees fresh layout
CSS
.list { width: 240px; outline: 1px dashed #94a3b8; }
.item { height: 20px; border-bottom: 4px solid #fff; background: #bfdbfe; }
.below { height: 16px; background: #fde68a; }
HTML
<div class="list"></div>
<div class="below"></div>
Resultlive, in your browser
What’s happening
  1. The list starts empty; the yellow bar sits right below it.
  2. The thrashing loop appends an item (20 px plus a 4 px white border, so 24 px each) and then reads list.offsetHeight. The reads come back 24, 48, 72, 96: every read already reflects the item appended a line earlier. The only way the browser can know that is by running layout right there, inside the loop. Four iterations, four forced layouts.
  3. The yellow bar moved down by 96 px: the layout that ran was real, for the whole page, not just for the list.
  4. The batched version appends four more items and reads once at the end: the same 96 px of growth, from a single layout. In a real page with hundreds of elements, the difference between N layouts and one is the difference between a janky and a smooth interaction.
  5. Note what this proves and what it doesn't: the changing values prove layout ran synchronously before each read. How long each layout took depends on the page and wasn't timed here.
AdvancedThrashing vs batching in application code

The same two patterns in application code:

JavaScript
// ❌ Thrashing: each iteration reads (forces layout) and then writes (dirties it again)
for (const card of cards) {
  card.style.height = container.offsetWidth / 3 + "px";
}

// ✅ Read once, then write
const third = container.offsetWidth / 3;
for (const card of cards) card.style.height = third + "px";

// ✅ When reads and writes come from different places: write in the next frame
requestAnimationFrame(() => {
  for (const card of cards) card.style.height = third + "px";
});
AdvancedWhat forces layout

Reads that force a synchronous layout

Any API that has to return geometry or a layout-dependent computed value. The common ones:

  • offsetTop, offsetLeft, offsetWidth, offsetHeight, offsetParent
  • clientTop, clientLeft, clientWidth, clientHeight
  • scrollTop, scrollLeft, scrollWidth, scrollHeight, and calling scrollTo() or scrollIntoView()
  • getBoundingClientRect(), getClientRects()
  • getComputedStyle(el) when you read a value that depends on layout (width of an auto-width element, for example)
  • innerText (it depends on what's rendered), focus() (it may need to scroll), window.innerWidth/innerHeight in some cases

Writes are cheap on their own. The cost appears when a read follows a write. DevTools' Performance panel marks these as forced reflows in the trace.

AdvancedKeeping layout work small: contain and content-visibility

Telling the browser what can't affect the rest of the page

contain: layout paint promises that nothing inside an element affects layout or painting outside it, so the browser can limit work to that subtree (Baseline 2022). content-visibility: auto goes further: an element that's off-screen isn't rendered at all (no style, layout or paint for its contents) until it's about to scroll into view (Baseline 2025, September). Pair it with contain-intrinsic-size so the skipped element still takes up space and the scrollbar doesn't jump.

CSS
.spacer { height: 1500px; }
.lazy { content-visibility: auto; contain-intrinsic-size: auto 500px; }
HTML
<div class="spacer">Scroll down…</div>
<section class="lazy"><p>Off-screen content the browser hasn't rendered yet.</p></section>
Resultlive, in your browser
What’s happening
  1. The section is 1500 px down the page, outside the test's 600 px tall frame, so content-visibility: auto lets the browser skip it.
  2. Its height is the placeholder 500 px from contain-intrinsic-size, not the height of its real content: the browser didn't lay the content out.
  3. checkVisibility({ contentVisibilityAuto: true }) on the paragraph inside returns false: the browser reports it as skipped. Scroll it into view (in a real page) and it's rendered normally; the auto keyword in contain-intrinsic-size then remembers its real size for next time.
  4. This is the cheapest way to speed up long pages (feeds, documentation, these study pages): rendering work is proportional to what's on screen, not the whole document.
How many layouts?
JavaScript
const boxes = document.querySelectorAll(".box"); // 100 boxes
for (const box of boxes) {
  box.style.width = box.offsetWidth + 10 + "px";
}

How many times does the browser run layout during this loop, and how would you fix it?

Show answer

Up to 100 forced layouts, one per iteration after the first, plus the normal one at the next frame. Fix it by reading all widths first, then writing.

What’s happening
  1. Iteration 1 reads box.offsetWidth. If nothing was dirty, that's free; then it writes style.width, which dirties layout.
  2. Iteration 2 reads offsetWidth of the next box. Layout is dirty, so the browser must run it now to give a correct answer, then the write dirties it again. The same happens in every following iteration: a forced layout per box.
  3. Fix: const widths = [...boxes].map((b) => b.offsetWidth); first (one layout at most), then boxes.forEach((b, i) => (b.style.width = widths[i] + 10 + "px")); (writes only). The browser lays out once, at the next frame.
  4. The demo above proves the mechanism (each read after a write returned the fresh value, so layout ran each time). The exact count Chrome performs isn't exposed to the page; DevTools shows each one as a separate Layout event.

#Animating cheaply: transform, opacity and will-change

Added

Smooth animation means producing a new frame every 16.7 ms (at 60 Hz). Animate width, height, margin, top or left and every frame needs style, layout, paint and composite on the main thread. Animate transform and opacity and the browser can usually skip layout and paint: it paints the element once into its own compositor layer, then each frame just moves or fades that layer, on the compositor thread, which keeps going even if the main thread is busy.

Mental model: animating left is redrawing the whole page for every frame of a cartoon; animating transform is sliding a cel over a painted background.

will-change: transform (or opacity) is a hint that a property is about to change, so the browser can create the layer ahead of time and avoid a hitch at the start. It's a hint with costs: each layer uses GPU memory, so use it on the few elements that need it, not on *.

The same movement, by margin and by transform
CSS
@keyframes by-margin { from { margin-left: 0; } to { margin-left: 200px; } }
@keyframes by-transform { from { transform: translateX(0); } to { transform: translateX(200px); } }
.row { display: flex; gap: 8px; margin-bottom: 8px; }
.mover { width: 50px; height: 40px; }
.next { width: 50px; height: 40px; background: #e2e8f0; }
.m { background: #fca5a5; animation: by-margin 2000ms linear infinite alternate; }
.t { background: #93c5fd; animation: by-transform 2000ms linear infinite alternate; }
HTML
<div class="row"><div class="mover m"></div><div class="next" id="nm"></div></div>
<div class="row"><div class="mover t"></div><div class="next" id="nt"></div></div>
Resultlive, in your browser
What’s happening
  1. Both boxes animate 200 px to the right over 2 s, one with margin-left, one with transform: translateX(). Paused half way (1000 ms), both are drawn at x = 112: visually identical.
  2. The margin version moved in layout (offsetLeft 112) and pushed its neighbour to 170. That means every frame of that animation is a layout of the row (and potentially more), on the main thread.
  3. The transform version's layout is untouched: it's still at 12 and the neighbour at 70. The browser only has to move the painted box, which is what lets it run on the compositor.
  4. The live result shows the visible cost of the margin version too: the grey neighbour box is shoved back and forth, which is rarely what you want.
  5. What isn't shown: frame rates and whether a layer was created. Those depend on the device and were not measured; check them with the DevTools Performance panel and the Layers panel (or "Layer borders" in the Rendering panel).
Advancedwill-change in practice

Using will-change without hurting performance

  • Add it shortly before the animation (on hover of the parent, or from JavaScript when a drag starts) and remove it afterwards, or keep it only on elements that animate constantly.
  • Never * { will-change: transform }: every element gets a layer, GPU memory balloons, and compositing gets slower, not faster ("layer explosion").
  • It has side effects you can measure: like a real transform, will-change: transform makes the element a containing block for fixed descendants and a stacking context, even before anything animates.
CSS
.wc { will-change: transform; width: 200px; height: 50px; margin-left: 30px; background: #ede9fe; }
.fixed { position: fixed; top: 0; left: 0; width: 14px; height: 14px; background: #7c3aed; }
HTML
<div class="wc"><div class="fixed"></div></div>
Resultlive, in your browser
What’s happening
  1. .wc only has will-change: transform; its transform is still none.
  2. Yet its position: fixed child sits at the box's own corner, not the viewport's (0, 0): the hint already changed the containing block, exactly as a transform would.
  3. So will-change isn't free even visually: adding it to a modal's ancestor can break the modal's positioning. Whether a compositor layer was actually created isn't observable from the page and wasn't checked here.
AdvancedFLIP: animating a layout change with transforms

First, Last, Invert, Play

Sometimes the layout really changes (an item moves to a new position in a list). The FLIP technique animates that with a transform instead of animating layout: record the element's position (First), apply the layout change (Last), apply a transform that puts it back where it was (Invert), then animate the transform to none (Play). The demo's test runs this exact code:

JavaScript
const first = el.getBoundingClientRect();            // F
neighbour.classList.add("moved");                     // the real layout change
const last = el.getBoundingClientRect();              // L
const dx = first.left - last.left;
el.animate(                                           // I + P
  [{ transform: `translateX(${dx}px)` }, { transform: "none" }],
  { duration: 300, easing: "ease-out" },
);
CSS
.row { display: flex; gap: 10px; }
.row div { width: 50px; height: 30px; background: #e2e8f0; }
.row .moved { margin-left: 200px; }
.row .y { background: #86efac; }
HTML
<div class="row"><div class="x"></div><div class="y"></div></div>
Resultlive, in your browser
What’s happening
  1. First: the green box is at left = 72.
  2. The neighbour gets a 200 px margin, a real layout change. Last: the green box is now at 272. The delta is -200.
  3. Invert: the animation's first keyframe is translateX(-200px), so at currentTime = 0 the box is drawn at 72 again: the user sees no jump.
  4. Play: the transform animates to none; half way (linear, for the test) it's at 172, at the end at its real position, 272.
  5. Layout ran for the two measurements; the 300 ms of movement are a transform animation that needs no layout per frame. Libraries like Framer Motion's layout animations and many list-reordering effects are built on this, and View transitions do something similar natively.

#Respecting prefers-reduced-motion

Added

Some people get dizziness, nausea or headaches from large on-screen motion (vestibular disorders), and others just find it distracting. Every major OS has a "reduce motion" setting, and CSS exposes it as the media feature prefers-reduced-motion, with the values no-preference and reduce (Baseline since 2020).

"Reduce" doesn't mean "remove all animation". It means avoid large, unexpected or continuous movement: parallax, zooming, sliding panels, auto-playing carousels. A fade or a colour change usually stays fine and keeps the interface understandable.

Mental model: treat motion as an enhancement you opt into. The safest structure is to write the static version first and add movement inside @media (prefers-reduced-motion: no-preference), so a browser or user that says "reduce" never gets it.

Motion only for people who haven't asked for less
CSS
.badge { width: 40px; height: 40px; border-radius: 50%; background: #60a5fa; }
@media (prefers-reduced-motion: no-preference) {
  @keyframes bob { from { translate: 0 0; } to { translate: 0 -8px; } }
  .badge { animation: bob 800ms ease-in-out infinite alternate; }
}
@media (prefers-reduced-motion: reduce) {
  .badge { outline: 3px solid #f59e0b; }
}
HTML
<div style="padding-top: 10px"><div class="badge"></div></div>
Resultlive, in your browser
What’s happening
  1. The badge is static by default. The bob animation is defined and applied only inside @media (prefers-reduced-motion: no-preference).
  2. When this page was checked, headless Chrome reported no-preference, so the badge had 1 animation and no outline. With "reduce motion" turned on in your OS, the live result above stays still and gets an amber outline instead; the test is written to expect exactly that in that case.
  3. The reduce block shows a non-motion alternative. In a real interface that might be a fade instead of a slide, or a static highlight instead of a pulsing one.
  4. The checker can't switch the setting itself, so the case it didn't run is the reduce one. To try it: DevTools → Rendering → "Emulate CSS media feature prefers-reduced-motion", or the OS setting (macOS: Accessibility → Display → Reduce motion; Windows: Accessibility → Visual effects → Animation effects off; iOS and Android have equivalents).
AdvancedA global safety net

Turning motion down everywhere

Many resets include a rule that makes every animation and transition effectively instant when the user asks for reduced motion:

CSS
@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
    scroll-behavior: auto !important;
  }
}
@keyframes spin { to { rotate: 1turn; } }
.spinner { width: 30px; height: 30px; background: #fca5a5; animation: spin 1s linear infinite; }
HTML
<div class="spinner"></div>
Resultlive, in your browser
What’s happening
  1. Inside @media (prefers-reduced-motion: reduce), every element gets a 0.01 ms animation duration, a single iteration and a 0.01 ms transition, all !important so component styles can't override them.
  2. The test can't switch the preference on, so it copies the rule out of the media query into its own <style>. With it applied, the infinite 1 s spinner computes animation-duration as 1e-05s (0.01 ms, serialised in seconds) and animation-iteration-count: 1, so the animation's whole run (endTime) is 0.01 ms: it's over by the next frame the browser draws.
  3. Removing the copy restores 1s and infinite, and the spinner has a running animation again, which is what users without the preference get.
  4. Why 0.01ms and not animation: none: the animation still runs (for an instant), so animationend and transitionend still fire and JavaScript waiting for them doesn't hang, and fill modes still leave elements in their end state.
  5. It's a blunt tool. It also kills motion that helps understanding, so prefer designing a reduced version per component, and keep the net as a backstop.
AdvancedReading the preference in JavaScript

For JavaScript-driven motion

Animations started from JavaScript (element.animate(), scroll effects, carousels, canvas) aren't touched by CSS media queries; check the preference yourself and listen for changes:

JavaScript
const reduce = matchMedia("(prefers-reduced-motion: reduce)");

function startCarousel() {
  if (reduce.matches) return; // no auto-advance
  // …
}
reduce.addEventListener("change", () => {
  if (reduce.matches) stopCarousel();
});

matchMedia returns a MediaQueryList whose matches is live; on the machine that checked this page it was false (the "no-preference" case, see the main demo's test). WCAG has two relevant criteria: 2.2.2 Pause, Stop, Hide (Level A) requires a way to stop anything that moves automatically for more than five seconds, and 2.3.3 Animation from Interactions (Level AAA) asks that motion triggered by interaction can be disabled. See What accessibility is and who it's for.

#Entry animations with @starting-style and transition-behavior

Added

Transitions need a "before" value, and an element that just appeared (inserted into the DOM, or switched from display: none) has none, so it can't fade or slide in with a transition. @starting-style fixes that: it declares the values an element should be treated as having before its first style, so the browser transitions from those to the real ones.

The opposite direction has a different problem: when an element goes to display: none it disappears instantly, before any exit transition can be seen, because display is a discrete property that flips immediately. transition-behavior: allow-discrete (also written as the keyword allow-discrete inside transition) lets discrete properties take part in a transition: display then stays visible for the whole exit transition and flips to none at the end.

Mental model: @starting-style is where the actor stands before walking on stage; allow-discrete is keeping the curtain open until they've walked off. Status: @starting-style and transition-behavior are Baseline 2024 (August, Firefox 129). Transitioning display itself works in Chrome 117+ and Safari 18 but not in Firefox as of October 2026 (MDN's "display animation" feature isn't Baseline); there, elements just appear and disappear, which is a fine fallback.

A toast that fades and slides in on its first render
CSS
.toast {
  width: 220px;
  padding: 10px 12px;
  border-radius: 8px;
  background: #1e293b;
  color: #f8fafc;
  opacity: 1;
  translate: 0 0;
  transition: opacity 400ms ease-out, translate 400ms ease-out;
}
@starting-style {
  .toast {
    opacity: 0;
    translate: 0 12px;
  }
}
HTML
<div class="toast">Saved</div>
Resultlive, in your browser
What’s happening
  1. .toast has its real values (opacity: 1, translate: 0 0) and a transition for both. Normally an element's first style never transitions.
  2. @starting-style { .toast { opacity: 0; translate: 0 12px } } gives the browser a "before" value to use for that first style. So as soon as the toast renders, there are two transitions, for opacity and translate.
  3. Their keyframes start at the starting-style values: opacity 0 → 1, translate 0px 12px → 0px. At currentTime = 0 the toast is fully transparent; at the end it's 1. The live result does this once when the frame loads (reload the page to see it again).
  4. The same happens to a toast inserted later from JavaScript: it gets its own two transitions on insertion. That's the real use case: notifications, list items, menus and dialogs that appear.
  5. @starting-style only applies to an element's first style (or when it goes from display: none to displayed); later changes transition from their real previous values as usual.
AdvancedExit animations: display and allow-discrete

Animating to and from display: none

A panel that fades in and out while switching display
CSS
.panel {
  display: none;
  opacity: 0;
  width: 200px;
  padding: 10px;
  background: #e0f2fe;
  transition: opacity 1000ms linear, display 1000ms allow-discrete;
}
.panel.open {
  display: block;
  opacity: 1;
}
@starting-style {
  .panel.open { opacity: 0; }
}
HTML
<div class="panel">Panel content</div>
Resultlive, in your browser
What’s happening
  1. Opening: adding .open changes display to block straight away (there's nothing to see while it's none, so it flips at the start), and @starting-style gives opacity a starting value of 0, so only the opacity transition runs. Without @starting-style the panel would pop in at full opacity.
  2. Closing: removing .open creates two transitions, display and opacity. Because of allow-discrete, display takes part in the transition instead of flipping immediately.
  3. Paused 1 ms before the end, the panel is still display: block with opacity 0.001: it stayed rendered for the whole fade. For display the browser keeps the visible value for the whole transition and swaps to none only at the end.
  4. After finishing, display is none. Drop display 1000ms allow-discrete from the transition and the panel vanishes the moment .open is removed, with no fade.
  5. In Firefox (October 2026) the display part isn't supported: the panel fades in, but disappears instantly when closed. Nothing breaks.
AdvancedPopovers, dialogs and the overlay property

Top-layer elements

<dialog> and [popover] elements are shown in the top layer, and removed from it when closed. To keep a closing one visible while it fades out, also transition the overlay property with allow-discrete (Chrome only as of October 2026; elsewhere it's ignored and the close is instant).

CSS
[popover] {
  opacity: 0;
  transition: opacity 500ms linear, display 500ms allow-discrete, overlay 500ms allow-discrete;
}
[popover]:popover-open { opacity: 1; }
@starting-style {
  [popover]:popover-open { opacity: 0; }
}
HTML
<div popover id="tip">A popover</div>
Resultlive, in your browser
What’s happening
  1. showPopover() (from JavaScript in the test; in a page a popovertarget button does it) makes it match :popover-open, and the starting style gives an opacity transition from 0.
  2. hidePopover() stops it matching :popover-open at once, but three transitions start: display, opacity and overlay.
  3. During the fade it still has display: block and overlay: auto (it stays in the top layer, above everything), although it no longer matches :popover-open.
  4. At the end both flip: display: none, overlay: none. The same pattern works for dialog[open] and ::backdrop. See details, dialog and popover for the elements themselves.
AdvancedGotcha: @starting-style and source order

#View transitions

Added

The View Transition API animates between two states of the page without you writing the animation. You call document.startViewTransition(update): the browser takes a snapshot of the current page, runs your update function (which changes the DOM however it likes), takes a snapshot of the new state, and animates from one to the other with pseudo-elements. By default the whole page cross-fades. Elements that have a view-transition-name get their own snapshot pair, and the browser animates their position and size from the old to the new place, which is the "shared element" or "morph" effect.

Mental model: it's FLIP done by the browser, for the whole page. You only describe the before and after states; the browser captures both and builds a temporary overlay of images to animate between them, while the real DOM is already in its new state underneath.

Status (MDN, October 2026): same-document view transitions (startViewTransition, view-transition-name, the pseudo-elements) are Baseline newly available since October 2025, when Firefox 144 shipped them; view-transition-class too. Cross-document transitions, between page loads with @view-transition { navigation: auto }, work in Chrome 126+ and Safari 18.2+ but not Firefox, so they're not Baseline. In unsupported browsers the update simply happens without animation, so it's a safe enhancement.

A card that expands, with the code that triggers it
CSS
.card {
  width: 120px;
  padding: 8px;
  border-radius: 8px;
  background: #dbeafe;
  view-transition-name: card;
}
.card.expanded {
  width: 300px;
  background: #fde68a;
}
::view-transition-group(card) {
  animation-duration: 400ms;
}
@media (prefers-reduced-motion: reduce) {
  ::view-transition-group(*), ::view-transition-old(*), ::view-transition-new(*) {
    animation: none;
  }
}
HTML
<div class="card">Card</div>
toggle.js (runs on click in a real page; the Result frame runs no scripts)
function toggle(card) {
  const update = () => card.classList.toggle("expanded");
  if (!document.startViewTransition) {
    update(); // no support: just change the DOM
    return;
  }
  return document.startViewTransition(update);
}
Resultlive, in your browser
What’s happening
  1. .card has view-transition-name: card, so during a transition it gets its own snapshot group, ::view-transition-group(card), separate from the page's root group. The stylesheet accepted that pseudo-element selector, and the CSS sets the group's animation to 400 ms.
  2. toggle() wraps the DOM change (classList.toggle("expanded")) in document.startViewTransition(), which returns a ViewTransition object. In a real page the browser would now snapshot the 120 px blue card, run the update, snapshot the 300 px yellow card, and animate the group's size and position between the two while cross-fading the snapshots.
  3. That animation needs real rendering frames, which headless Chrome under the checker's virtual time doesn't produce, so the test calls skipTransition(). That proves what skipping does: the update callback still runs (the card is expanded), ready rejects with an AbortError, and finished resolves. Your code can rely on the DOM change happening either way.
  4. The if (!document.startViewTransition) branch is the progressive-enhancement fallback: with the API removed, toggle() returns undefined and simply toggles the class (back to not expanded here).
  5. What was not verified here: the animation itself (how the morph looks, its timing). The Result frame can't show it either, because it runs no scripts. Try the code in a page of your own, and use the DevTools Animations panel to slow it down.
  6. The reduced-motion block removes the animations for users who asked for less motion; the update still happens. The API doesn't do that by itself.
AdvancedThe pseudo-element tree and the default animation

What the browser builds during a transition

While a transition runs, the browser puts an overlay above the page, made of pseudo-elements (MDN's tree):

Output
::view-transition
└─ ::view-transition-group(root)
   └─ ::view-transition-image-pair(root)
      ├─ ::view-transition-old(root)    the screenshot of the old state
      └─ ::view-transition-new(root)    the live new state
└─ ::view-transition-group(card)        one group per view-transition-name
   └─ ::view-transition-image-pair(card)
      ├─ ::view-transition-old(card)
      └─ ::view-transition-new(card)
  • By default ::view-transition-old() fades from opacity 1 to 0 and ::view-transition-new() from 0 to 1, a cross-fade.
  • Each ::view-transition-group() animates its size and position from where the named element was to where it is now. Styles on the group (like animation-duration) are inherited by the old and new pseudo-elements, so it's the place for shared settings.
  • They're ordinary CSS animations: you can replace them with your own @keyframes, per name, and ViewTransition.ready is the moment to start custom Web Animations on them.
  • Each view-transition-name must be unique among the elements rendered at that moment. Per MDN, if two rendered elements share a name, ready rejects and the transition is skipped. That case needs a real rendered frame and wasn't checked here.
  • For lists, view-transition-name: match-element gives every matching element its own internal name (same-document only), and view-transition-class lets you style many named groups with one selector: ::view-transition-group(*.card) { … }.
AdvancedCross-document view transitions

Between page loads

For multi-page sites, both the old and the new page opt in with an at-rule; there's no JavaScript call. Same-origin navigations then get the default cross-fade, and elements with matching view-transition-name on both pages morph from one to the other.

CSS
@view-transition {
  navigation: auto;
}
.hero { view-transition-name: hero; width: 80px; height: 40px; background: #c4b5fd; }
HTML
<div class="hero"></div>
Resultlive, in your browser
What’s happening
  1. @view-transition { navigation: auto; } is parsed by Chrome 155 into a CSSViewTransitionRule with navigation: "auto". It has to be on both pages, and it applies to same-origin navigations.
  2. Transition types let you vary the animation by direction: start one with document.startViewTransition({ update, types: ["forward"] }) and style it with :active-view-transition-type(forward), a selector Chrome accepts (CSS.supports is true). The options form with types has been Baseline since January 2026.
  3. Cross-document transitions aren't Baseline (no Firefox support in October 2026), but browsers without them just navigate normally, so they're safe to add. Whether the cross-page animation actually plays wasn't tested on this page (the checker loads a single document).

#Loading performance: render-blocking CSS, critical CSS and fonts

Added

The browser won't paint a page until it has the CSS for it. A parser-inserted <link rel="stylesheet"> whose media matches is render-blocking (the HTML spec's term): rendering waits until it has downloaded and parsed, so users don't see unstyled content flash. It's also script-blocking: a classic <script> after it won't run until the stylesheet has loaded, because the script might read styles. So the size, number and discovery time of your stylesheets directly delay the first paint.

Fonts add a second wait. Text in a web font can't be drawn until the font file arrives, and what the browser shows meanwhile (nothing, or a fallback font that later swaps) is controlled by font-display. A late swap to a font with different metrics reflows the text, which is a layout shift.

Mental model: the first paint is a meal that can't be served until every dish in the critical order is ready. You speed it up by ordering less (critical CSS), ordering everything at once (no @import chains, preload for late-discovered files), and deciding what to serve while the font is still cooking (font-display).

Font loading policy and a metric-matched fallback
CSS
@font-face {
  font-family: "Brand";
  src: local("Helvetica Neue"), local("Arial");
  font-display: swap;
}
@font-face {
  font-family: "Arial Narrowed";
  src: local("Arial");
  size-adjust: 90%;
}
.sample { display: inline-block; font-size: 24px; white-space: nowrap; }
.arial { font-family: Arial, sans-serif; }
.narrowed { font-family: "Arial Narrowed", sans-serif; }
HTML
<style media="print">
  .sample { color: rgb(255, 0, 0); }
</style>
<div><span class="sample arial">Layout shift</span></div>
<div><span class="sample narrowed">Layout shift</span></div>
Resultlive, in your browser
What’s happening
  1. Each @font-face rule becomes a FontFace object in document.fonts, and its descriptors read back: "Brand" has display: "swap"; "Arial Narrowed" didn't set font-display, so it's "auto" (the browser's default, which in practice behaves like block).
  2. font-display: swap means: show the text in a fallback font almost immediately (the spec recommends a block period of 100 ms or less) and swap in the web font whenever it arrives. Text is readable early, at the cost of a visible swap.
  3. "Arial Narrowed" is Arial with size-adjust: 90%, and the same text in it is measured at 0.9 times the width of plain Arial. That's how you make a fallback font match the web font's size: define a local fallback with size-adjust (Baseline 2023) tuned so the swap doesn't move the text, and list it right after the web font in font-family.
  4. The <style media="print"> sheet is loaded (it has 1 rule) but doesn't apply to the screen: the text keeps the default colour. For <link rel="stylesheet" media="print"> that also means it doesn't block rendering: the spec only blocks rendering on stylesheets whose media matches.
  5. Not measured here: download timing, the block and swap periods, and actual layout-shift scores. Those need a real network and a real page load; use the Network and Performance panels or Lighthouse for them.
AdvancedA fast <head>, line by line

What a fast <head> looks like, putting the rules together:

HTML
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Product page</title>

  <!-- Start connecting to the font host early -->
  <link rel="preconnect" href="https://fonts.example.com" crossorigin>
  <!-- The one font the first screen needs: fetch it now, not after the CSS is parsed -->
  <link rel="preload" href="/fonts/brand.woff2" as="font" type="font/woff2" crossorigin>

  <!-- Critical CSS for the first screen, inline: no extra request blocks the first paint -->
  <style>
    /* header, hero, layout grid, fonts with font-display: swap … */
  </style>

  <!-- The rest of the CSS: render-blocking only if it must be -->
  <link rel="stylesheet" href="/css/main.css">
  <link rel="stylesheet" href="/css/print.css" media="print">

  <!-- Scripts that don't block parsing -->
  <script src="/js/app.js" defer></script>
</head>
What’s happening
  1. preconnect opens the connection (DNS, TCP, TLS) to a third-party font host before any request needs it; crossorigin is there because fonts are fetched in CORS mode, and a preconnect without it can't be reused for them.
  2. rel="preload" as="font" starts downloading a font that would otherwise only be discovered after the CSS that uses it is downloaded and parsed. Font preloads must have crossorigin, even for same-origin fonts, or the preloaded file isn't used and the font is fetched twice.
  3. Inline critical CSS lets the first screen render after just the HTML arrives. The rest can load normally; it's then the difference between "the page is usable" and "the page is complete".
  4. media="print" on the print stylesheet means it downloads without blocking the screen render. (A known trick loads non-critical CSS with media="print" onload="this.media='all'"; it works, but needs JavaScript and an inline handler, which a strict Content Security Policy forbids.)
  5. defer scripts download in parallel and run after parsing, in order, so they don't hold up the parser. A classic script placed after a stylesheet would also wait for that stylesheet, so blocking scripts in the <head> are the worst case.
Advancedfont-display values

What each font-display value does

The font display timeline has a block period (text is drawn invisible if the font isn't ready), then a swap period (a fallback is shown and replaced if the font arrives), then a failure period (the fallback stays). The values set the lengths; the durations below are the CSS Fonts 4 recommendations.

ValueBlock periodSwap periodFeels like
blockshort (3 s recommended)infiniteinvisible text, then the font ("FOIT")
swapextremely small (100 ms or less)infinitefallback text at once, swaps later ("FOUT")
fallbackextremely small (100 ms or less)short (3 s recommended)swaps only if the font is fast; otherwise keeps the fallback
optionalextremely small (100 ms or less)noneuses the font only if it's already there (cached or very fast)
autobrowser's choicebrowser's choiceusually like block

swap is the common choice for body text and branding; optional gives the most stable layout (no late swap at all) and suits text where the exact font is nice-to-have. All of font-display is Baseline since 2020.

AdvancedLayout shift: causes and fixes

Cumulative Layout Shift

CLS (one of the Core Web Vitals) adds up how much visible content moves unexpectedly while the page is in use; a "good" score is 0.1 or less. The usual causes and fixes:

  • Images and embeds without dimensions: the space is only reserved once the file arrives. Always set width and height attributes (the browser derives an aspect-ratio from them) or aspect-ratio in CSS; see Images, figure and media.
  • Late web fonts with different metrics: use font-display: optional, or a fallback tuned with size-adjust (as in the demo). The finer ascent-override, descent-override and line-gap-override descriptors exist in Chrome and Firefox but not Safari (not Baseline, October 2026).
  • Content injected above existing content (banners, ads, cookie bars): reserve the space in advance, or overlay instead of pushing.
  • Animating layout properties: use transform (see Animating cheaply); transform changes don't count as layout shifts.

Measuring: the Layout Instability API that reports shifts to JavaScript is Chrome-only, so CLS field data comes from Chromium browsers; Largest Contentful Paint and Event Timing (behind INP) became Baseline in December 2025 with Safari 26.2.

Written fresh for interviews (there was no Google Doc for this one). Every live example was checked in headless Chrome 155 by scripts/check-demos.mjs.

RohitDownloads · All chapters

Sync progress across devices

Type the same private phrase on your Mac and your phone, and your Learned ticks follow you between them — on every study site.

The phrase never leaves this device: only a fingerprint of it is sent, and the server stores a fingerprint of that. Anyone who knows the phrase could see or change your ticks, so pick something you don’t use elsewhere.

Sync is on in this browser.

Sync ID

This ID must be the same on every device. If another device shows a different one, its phrase is different (capital letters count): tap “Turn off here” on it and type the phrase again exactly.