Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

CSS  Module 23 – Debugging, performance, accessibility, tooling and print

Debugging CSS systematically

A repeatable way to find why CSS looks wrong: reproduce, read the Styles and Computed panes, check the layout mode, bisect, and spot eight common bugs.

  • Intermediate
  • 30 minutes
  • Examples run with Node.js 24.21.0 and quickjs 0.32.0
  • By MySmartCoPilot
Free: 30 minutes

What you will learn

  • Follow a repeatable process to find why a style or layout is wrong
  • Use each browser's tools for inactive declarations, layout overlays and computed values
  • Recognise frequent CSS bugs (overflow, margin collapse, stacking, min-width auto, missing container sizes) by their symptoms
  • Find the elements that make a page scroll sideways, and bisect a stylesheet when nothing else points to the cause

Before you start

What you’ll learn · Before you start

What you will learn

  • Follow a repeatable process to find why a style or layout is wrong
  • Use each browser's tools for inactive declarations, layout overlays and computed values
  • Recognise frequent CSS bugs (overflow, margin collapse, stacking, min-width auto, missing container sizes) by their symptoms
  • Find the elements that make a page scroll sideways, and bisect a stylesheet when nothing else points to the cause

Before you start

On this page

CSS rarely fails loudly. A wrong value raises no error: the browser simply draws something you did not intend. To debug CSS systematically is to run the same loop every time instead of changing values until the page looks right. Reproduce the bug at one width in one browser. Select the element and read the Styles pane: which rules matched, which declarations lost, which could not be parsed and which have no effect. Read the computed value the browser actually used. Check the layout mode of the element’s parent, because flex, grid, positioning and containment change what most properties do. Then change one thing and check again. When none of that gives you a lead, bisect: switch off half of the rules, keep the half that still shows the bug, and repeat.

This lesson builds on the panes introduced in Inspecting CSS with browser DevTools. It adds debugging styles that change nothing but what you see, an overflow finder for pages that scroll sideways, eight common bugs with the signal that gives each one away, measured before and after the fix in Chromium 153, Firefox 155 and WebKit 26.6, and what to check before you blame the browser.

The loop

A loop of six debugging steps: reproduce, read the Styles pane, read the computed value, check the parent, change one thing, or bisect.1. Reproduce itone width · one browser · one element2. Read the Styles panematched · crossed out · invalid · inactive3. Read the computed valuethe number the browser used4. Check the parent's layoutflow · flex · grid · positioned · containment5. Change one thingfirst in DevTools, then in the file6. No lead? Bisecthalve the rules, then the markupa leadno leada short list of suspectscheck every width again

A debugging loop for CSS, from reproducing the bug to checking the fix

Text description of the diagram

The diagram shows six steps in a column, with arrows between them.

  1. Reproduce it: at one width, in one browser, on one element.
  2. Read the Styles pane: which rules match, which declarations are crossed out, which are invalid and which are inactive.
  3. Read the computed value: the number the browser actually used.
  4. Check the parent's layout: normal flow, flex, grid, positioning or containment.
  5. With a lead, change one thing, first in DevTools and then in the file. An arrow leads from this step back to step 1: check every width again.
  6. With no lead after step 4, bisect: halve the rules, then the markup. An arrow leads from this step back to step 2 with a short list of suspects.

Each step answers one question, and the answer decides the next step.

  1. Reproduce it. Fix the width with Chrome’s device mode or the Responsive Design Mode of Firefox and Safari, so the bug appears every time you reload. Note the one element that looks wrong. A bug you cannot repeat at will cannot be checked when you think you have fixed it.
  2. Read the Styles pane. Is the rule you expected listed at all? If not, its selector does not match or the file never loaded. If it is listed, its declaration is either crossed out (another one won the cascade), flagged as invalid (the parser dropped it), pale with an information icon (valid but inactive, in Chrome and Firefox), or in effect.
  3. Read the computed value. The Computed pane shows the number the browser used, after percentages, em, auto and custom properties were worked out. A declaration that looks right in Styles but computes to an initial value has a problem the Styles pane cannot show, as bug 7 below does.
  4. Check the parent’s layout mode. Most surprises come from the box around the element: a flex or grid parent, a positioned ancestor, a scroll container or a query container. The badges in the DOM tree (flex, grid, scroll, and in Chrome also container) show these modes at a glance.
  5. Change one thing. Try the fix in DevTools first, where it costs nothing to undo, then make the same change in the source file and check every width again. Two changes at once tell you nothing about which one helped.
  6. Bisect when you have no lead. Halving the rules finds the cause without a theory, and it is the subject of a section below.

Debugging styles that change nothing else

Sometimes you need to see every box at once: where a gap comes from, which box is wider than it looks. The quickest way is a temporary rule that draws a line around every element:

Temporary debugging styles: a coloured outline at each depth CSS · debug.css
/* Temporary debugging styles. Add them in DevTools (a new style rule, or a new sheet in Firefox's Style Editor) while
   you hunt a bug, and never ship them. Outlines take no space, so nothing moves; each depth gets its own colour,
   which shows how boxes nest. */
* { outline: 1px solid rgb(220 38 38 / 0.7); }
* * { outline-color: rgb(37 99 235 / 0.7); }
* * * { outline-color: rgb(22 163 74 / 0.7); }
* * * * { outline-color: rgb(202 138 4 / 0.7); }

Use outline, never border, for this. An outline is drawn outside the border and takes no space, so the layout stays exactly as it was, while a border adds to each box’s size and can change the very thing you are looking at. The page below lays out three 120 px boxes in a 360 px row and measures the row with each debugging style in turn:

The same row with outlines and with borders HTML · outline-or-border.html
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Outlining every box with outline, then with border</title>
  <style>
    body { margin: 0; font: 16px/24px Arial, sans-serif; }
    .row { display: flex; flex-wrap: wrap; inline-size: 360px; }
    .row > div { box-sizing: content-box; inline-size: 120px; block-size: 48px; }
    /* The two debug styles, switched on one at a time by the script below. */
    .debug-outline * { outline: 1px solid red; }
    .debug-border * { border: 1px solid red; }
  </style>
</head>
<body>
  <div class="row"><div>One</div><div>Two</div><div>Three</div></div>
  <script>
    const row = document.querySelector('.row');
    const report = (label) => {
      const lines = new Set([...row.children].map((c) => c.getBoundingClientRect().top));
      console.log(`${label}: the row is ${row.getBoundingClientRect().height}px tall, ${lines.size} line${lines.size === 1 ? '' : 's'} of boxes`);
    };
    report('no debug style');
    document.body.className = 'debug-outline';
    report('outline on every box');
    document.body.className = 'debug-border';
    report('border on every box');
  </script>
</body>
</html>
Debugging style Row height Lines of boxes
none 48 px 1
outline: 1px solid red on every element 48 px 1
border: 1px solid red on every element 102 px 2

All three engines measured the same: with borders, each box became 122 px wide, the third no longer fitted, and the row wrapped. A debugging style that moves boxes around can hide the bug or invent a new one.

A debug layer, and where it wins

When you want to try overrides in a file rather than in DevTools, a cascade layer named debug looks like a way to win without raising specificity. It works only when the rest of the site’s CSS is in layers too. Declarations that are in no layer at all count as one implicit layer that comes after every named layer, so they beat any layered declaration, whatever its selector. The page below tests four set-ups:

Four ways a debug rule meets the site's rules HTML · debug-layer.html
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Where a debug layer wins</title>
  <style>
    /* Case A: the site's own styles are unlayered. */
    #a .card .title { color: rgb(51, 51, 51); }
    @layer debug-a { #a .title { color: rgb(255, 0, 0); } }

    /* Case B: the site's styles are all in layers, and debug is the last layer. */
    @layer base, components, debug-b;
    @layer components { #b .card .title { color: rgb(51, 51, 51); } }
    @layer debug-b { #b .title { color: rgb(255, 0, 0); } }

    /* Case C: unlayered site styles, and !important inside the debug layer. */
    #c .card .title { color: rgb(51, 51, 51); }
    @layer debug-c { #c .title { color: rgb(255, 0, 0) !important; } }

    /* Case D: like C, but the site also used !important, in an earlier layer. */
    @layer vendor { #d .card .title { color: rgb(51, 51, 51) !important; } }
    @layer debug-d { #d .title { color: rgb(255, 0, 0) !important; } }
  </style>
</head>
<body>
  <div id="a"><div class="card"><p class="title">A</p></div></div>
  <div id="b"><div class="card"><p class="title">B</p></div></div>
  <div id="c"><div class="card"><p class="title">C</p></div></div>
  <div id="d"><div class="card"><p class="title">D</p></div></div>
  <script>
    for (const id of ['a', 'b', 'c', 'd']) {
      const color = getComputedStyle(document.querySelector(`#${id} .title`)).color;
      console.log(`case ${id.toUpperCase()}: ${color === 'rgb(255, 0, 0)' ? 'the debug rule wins' : 'the site rule wins'} (${color})`);
    }
  </script>
</body>
</html>
Case: the site’s rule The debug rule Winner in all three engines
A: unlayered in a debug layer the site’s rule
B: in a layer, with debug listed last in the debug layer the debug rule
C: unlayered !important in a debug layer the debug rule
D: !important in an earlier layer !important in a debug layer the site’s rule

Case D follows from how importance reverses the order: for !important declarations the earliest layer wins, and unlayered ones come last. So a debug layer with !important beats everything in the author’s stylesheets except !important declarations in layers declared before it and !important in a style attribute. If the project already has a layer plan, add debug as its last layer for the length of the bug hunt, and delete it before you ship.

Find what makes a page scroll sideways

A page that scrolls sideways on a phone is one of the most common CSS bug reports, and the cause is rarely on the part of the page you are looking at. Firefox marks scroll containers with a scroll badge in its DOM tree and adds an overflow badge to the elements that cause the overflow. In any browser, a short script in the console can list them. This landing page has five planted causes, and one wide table inside a box of its own that scrolls, which is not a bug:

A landing page with five causes of sideways scrolling, and the overflow finder

overflow/broken.html

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>A landing page that scrolls sideways on a phone</title>
  <style>
    body { margin: 0; font: 16px/24px Arial, sans-serif; }
    .page { padding: 16px; }
    .menu { position: absolute; inset-block-start: 0; inset-inline-end: 0; inline-size: 280px; block-size: 100vh; background: #1d4ed8; transform: translateX(100%); }
    .promo { margin-inline-end: -24px; padding: 16px; background: #fbbf24; }
    .card { padding: 16px; background: #e8eefc; }
    .photo { inline-size: 480px; block-size: 160px; background: #93a3b8; }
    table { border-collapse: collapse; }
    td { padding: 4px 12px; white-space: nowrap; }
    .table-scroller { overflow-x: auto; }
  </style>
</head>
<body>
  <nav class="menu">Menu</nav>
  <div class="page">
    <h1>Monsoon sale</h1>
    <p class="promo">Free delivery on orders over 499</p>
    <div class="card">
      <p class="tracking">Your tracking code: 9c56cc51b374c3ba189210d5b6d4bf57790d351c96c47c02190ecf1e430635ab</p>
    </div>
    <div class="photo" id="hero"></div>
    <table class="prices"><tr><td>Notebook</td><td>Pen set</td><td>Backpack</td><td>Water bottle</td></tr></table>
    <div class="table-scroller">
      <table class="sizes"><tr><td>XS 34</td><td>S 36</td><td>M 38</td><td>L 40</td><td>XL 42</td><td>XXL 44</td></tr></table>
    </div>
  </div>
  <script src="finder.js"></script>
</body>
</html>

overflow/finder.js

// The overflow finder. Paste it into the DevTools console of a page that scrolls sideways, or load it with a
// <script> tag. It lists the outermost elements whose box ends past the right edge of the page, and the innermost
// elements whose own text runs past it (a long URL leaves its paragraph's box where it was). Elements inside a box
// that scrolls or clips sideways are left out, because that box holds their overflow, and so are fixed boxes, which
// never make the page wider.
(() => {
  const edge = document.documentElement.clientWidth;
  const name = (el) => el.tagName.toLowerCase() + (el.id ? `#${el.id}` : '') + [...el.classList].map((c) => `.${c}`).join('');
  const held = (el) => {
    for (let box = el; box && box !== document.body; box = box.parentElement) {
      const style = getComputedStyle(box);
      if (style.position === 'fixed' || (box !== el && style.overflowX !== 'visible')) return true;
    }
    return false;
  };
  const sticksOut = (el) => el.getBoundingClientRect().right > edge + 0.5;
  const boxes = [];
  const texts = [];
  for (const el of document.body.querySelectorAll('*')) {
    if (held(el)) continue;
    const box = el.getBoundingClientRect();
    if (box.width === 0 && box.height === 0) continue;
    if (sticksOut(el)) {
      if (!boxes.some((b) => b.el.contains(el))) boxes.push({ el, past: Math.round(box.right - edge) });
      continue;
    }
    // The box fits, but what it holds does not, and no child element is the reason: its own text overflows.
    const contentRight = box.left + el.clientLeft + el.scrollWidth;
    if (contentRight > edge + 0.5 && ![...el.children].some(sticksOut)) texts.push({ el, past: Math.round(contentRight - edge) });
  }
  // Keep the innermost element of each run of overflowing text.
  const text = texts.filter((t) => !texts.some((o) => o !== t && t.el.contains(o.el)));
  console.log(`${boxes.length} box${boxes.length === 1 ? '' : 'es'} and ${text.length} text${text.length === 1 ? '' : 's'} end past the right edge of a ${edge}px page`);
  for (const b of boxes) console.log(`  box  ${name(b.el)} ends ${b.past}px past`);
  for (const t of text) console.log(`  text ${name(t.el)} runs ${t.past}px past`);
})();

The finder walks the page and reports two kinds of offender: the outermost elements whose box ends past the right edge, and the innermost elements whose own text runs past it, because a long unbreakable string leaves its paragraph’s box where it was and only the text spills. It skips elements inside a box that scrolls or clips sideways, since that box holds their overflow, and fixed boxes, which never make the page wider. At 360 px wide, the three engines printed:

Offender How far past the edge The fix
nav.menu 280 px the closed menu is absolutely positioned: make it position: fixed
p.promo 8 px a negative margin wider than the page’s padding: remove it, or match the padding
div#hero.photo 136 px a fixed width of 480 px: max-inline-size: 100%
table.prices 30 px a table wider than the screen: wrap it in a box with overflow-x: auto
text of p.tracking 225 px (Firefox 226 px) a 64-character code with no break opportunity: overflow-wrap: anywhere

The second table, inside .table-scroller, was correctly left out. The one pixel between engines is a difference in how each draws the font, not in layout. Resist the shortcut of overflow-x: hidden on body: it hides the symptom and the content with it, and it turns the body into a scroll container, which stops sticky headers from sticking, as the overflow lesson measured.

Eight bugs and the signal that gives each away

Most CSS bugs belong to a small family, and each family leaves a recognisable trace in DevTools. These eight minimal reproductions each put the bug next to its fix and measure both:

Eight bugs, each before and after its fix

1 margin · bugs/1-margin.html

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Bug 1: the heading's margin escapes the card</title>
  <style>
    body { margin: 0; font: 16px/24px Arial, sans-serif; }
    .card { inline-size: 280px; margin-block-end: 16px; background: #e8eefc; }
    .card h2 { margin-block: 24px 8px; font-size: 20px; line-height: 28px; }
    .card p { margin: 0; }
    /* The fix: a new block formatting context keeps the heading's margin inside the card. */
    .fixed { display: flow-root; }
  </style>
</head>
<body>
  <div class="card" id="before"><h2>Order shipped</h2><p>Arrives on Friday.</p></div>
  <div class="card fixed" id="after"><h2>Order shipped</h2><p>Arrives on Friday.</p></div>
  <script>
    for (const id of ['before', 'after']) {
      const card = document.getElementById(id).getBoundingClientRect();
      const heading = document.querySelector(`#${id} h2`).getBoundingClientRect();
      console.log(`${id}: heading ${heading.top - card.top}px below the card's top edge, card ${card.height}px tall`);
    }
  </script>
</body>
</html>

2 flex · bugs/2-flex-shrink.html

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Bug 2: a wide table pushes the sidebar off a phone screen</title>
  <style>
    body { margin: 0; font: 16px/24px Arial, sans-serif; }
    .layout { display: flex; gap: 16px; inline-size: 360px; margin-block-end: 16px; }
    .layout main { flex: 1; }
    .layout aside { flex: 0 0 96px; background: #e8eefc; }
    table { inline-size: 480px; border-collapse: collapse; }
    /* The fix: let the column shrink below its content, and let the table scroll in a box of its own. */
    .fixed main { min-inline-size: 0; }
    .fixed .scroller { overflow-x: auto; }
  </style>
</head>
<body>
  <div class="layout" id="before">
    <main><div class="scroller"><table><tr><td>Order</td><td>Item</td><td>Status</td><td>Total</td></tr></table></div></main>
    <aside>Filters</aside>
  </div>
  <div class="layout fixed" id="after">
    <main><div class="scroller"><table><tr><td>Order</td><td>Item</td><td>Status</td><td>Total</td></tr></table></div></main>
    <aside>Filters</aside>
  </div>
  <script>
    for (const id of ['before', 'after']) {
      const layout = document.getElementById(id).getBoundingClientRect();
      const main = document.querySelector(`#${id} main`).getBoundingClientRect();
      const aside = document.querySelector(`#${id} aside`).getBoundingClientRect();
      const past = aside.right - layout.right;
      console.log(`${id}: main ${main.width}px wide, sidebar ${past > 0 ? `ends ${past}px past` : 'inside'} the 360px layout`);
    }
  </script>
</body>
</html>

3 z-index · bugs/3-z-index.html

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Bug 3: z-index does nothing on the badge</title>
  <style>
    body { margin: 0; font: 16px/24px Arial, sans-serif; }
    .tile { margin-block-end: 24px; }
    .badge { inline-size: 80px; block-size: 32px; margin-block-end: -16px; background: #fbbf24; z-index: 2; }
    .photo { position: relative; inline-size: 200px; block-size: 120px; background: #93a3b8; }
    /* The fix: z-index needs a positioned box (or a flex or grid item). */
    .fixed .badge { position: relative; }
  </style>
</head>
<body>
  <div class="tile" id="before"><div class="badge">New</div><div class="photo"></div></div>
  <div class="tile fixed" id="after"><div class="badge">New</div><div class="photo"></div></div>
  <script>
    for (const id of ['before', 'after']) {
      const badge = document.querySelector(`#${id} .badge`).getBoundingClientRect();
      // A point in the strip where the badge and the photo overlap.
      const top = document.elementFromPoint(badge.left + 40, badge.bottom - 8);
      console.log(`${id}: the ${top.className} is on top where the two overlap`);
    }
  </script>
</body>
</html>

4 fixed · bugs/4-fixed-in-transform.html

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Bug 4: a fixed header scrolls away with the page</title>
  <style>
    body { margin: 0; font: 16px/24px Arial, sans-serif; }
    /* Left behind by a slide-in animation: a transform makes .page the containing block of fixed descendants. */
    .page { transform: translateX(0); }
    header { position: fixed; inset-block-start: 0; inline-size: 100%; block-size: 48px; background: #1d4ed8; color: #fff; }
    main { block-size: 2000px; padding-block-start: 48px; }
    /* The fix: no transform on an ancestor of the fixed header once the animation is over. */
    .page.fixed { transform: none; }
  </style>
</head>
<body>
  <div class="page"><header>Shop</header><main>Products</main></div>
  <script>
    const page = document.querySelector('.page');
    const header = document.querySelector('header');
    window.scrollTo(0, 400);
    console.log(`before: after scrolling 400px, the header's top is at ${header.getBoundingClientRect().top}px`);
    page.classList.add('fixed');
    console.log(`after: after scrolling 400px, the header's top is at ${header.getBoundingClientRect().top}px`);
  </script>
</body>
</html>

5 container · bugs/5-container-size.html

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Bug 5: the query container collapses to nothing</title>
  <style>
    body { margin: 0; font: 16px/24px Arial, sans-serif; }
    .toolbar { display: flex; gap: 8px; inline-size: 360px; margin-block-end: 16px; }
    .toolbar button { flex: none; inline-size: 80px; }
    /* A flex item sized by its content, but inline-size containment ignores the content: it shrinks to zero. */
    .search { container-type: inline-size; background: #e8eefc; }
    .search input { inline-size: 100%; }
    .hint { display: none; }
    @container (inline-size > 200px) { .hint { display: inline; } }
    /* The fix: give the container a size that does not come from its content. */
    .fixed .search { flex: 1; }
  </style>
</head>
<body>
  <div class="toolbar" id="before"><div class="search"><input aria-label="Search"> <span class="hint">Press / to search</span></div><button>Go</button></div>
  <div class="toolbar fixed" id="after"><div class="search"><input aria-label="Search"> <span class="hint">Press / to search</span></div><button>Go</button></div>
  <script>
    for (const id of ['before', 'after']) {
      const search = document.querySelector(`#${id} .search`).getBoundingClientRect();
      const hint = getComputedStyle(document.querySelector(`#${id} .hint`)).display;
      console.log(`${id}: container ${search.width}px wide, the hint is ${hint === 'none' ? 'hidden' : 'shown'}`);
    }
  </script>
</body>
</html>

6 height · bugs/6-percent-height.html

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Bug 6: height: 100% does nothing</title>
  <style>
    body { margin: 0; font: 16px/24px Arial, sans-serif; }
    .panel { min-block-size: 200px; margin-block-end: 16px; background: #e8eefc; }
    /* The parent has only a minimum height, so its height is auto and the percentage behaves as auto. */
    .sidebar { inline-size: 120px; block-size: 100%; background: #93a3b8; }
    /* The fix: let a grid row stretch the sidebar instead of a percentage. */
    .fixed { display: grid; }
    .fixed .sidebar { block-size: auto; }
  </style>
</head>
<body>
  <div class="panel" id="before"><div class="sidebar">Menu</div></div>
  <div class="panel fixed" id="after"><div class="sidebar">Menu</div></div>
  <script>
    for (const id of ['before', 'after']) {
      const sidebar = document.querySelector(`#${id} .sidebar`).getBoundingClientRect();
      console.log(`${id}: sidebar ${sidebar.height}px tall in a 200px panel`);
    }
  </script>
</body>
</html>

7 var() · bugs/7-var-unit.html

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Bug 7: a custom property makes the margin disappear</title>
  <style>
    body { margin: 0; font: 16px/24px Arial, sans-serif; }
    :root { --space: 24; }
    /* var(--space)px becomes the two tokens "24" and "px", not the length 24px: invalid when the value is computed. */
    #before { margin-block-start: var(--space)px; }
    /* The fix: multiply the number by a unit. */
    #after { margin-block-start: calc(var(--space) * 1px); }
  </style>
</head>
<body>
  <p id="before">Before</p>
  <p id="after">After</p>
  <script>
    for (const id of ['before', 'after']) {
      console.log(`${id}: computed margin-top ${getComputedStyle(document.getElementById(id)).marginTop}`);
    }
  </script>
</body>
</html>

8 off-canvas · bugs/8-offcanvas.html

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Bug 8: a closed slide-in menu makes the page scroll sideways</title>
  <style>
    body { margin: 0; font: 16px/24px Arial, sans-serif; }
    /* Closed: moved off the right edge, but an absolutely positioned box still adds to the page's scrollable area. */
    .menu { position: absolute; inset-block-start: 0; inset-inline-end: 0; inline-size: 280px; block-size: 100%; background: #1d4ed8; transform: translateX(100%); }
    /* The fix: a fixed box belongs to the viewport and never makes the page scrollable. */
    .fixed .menu { position: fixed; }
  </style>
</head>
<body>
  <nav class="menu">Menu</nav>
  <script>
    const root = document.documentElement;
    const measure = (label) => console.log(`${label}: the page is ${root.scrollWidth}px wide in a ${root.clientWidth}px viewport`);
    measure('before');
    document.body.classList.add('fixed');
    measure('after');
  </script>
</body>
</html>
Bug and symptom Cause, and where DevTools shows it Fix
1. A gap above a card instead of inside it The first child’s top margin collapses through the card; the margin highlight sits outside the card’s box display: flow-root on the card
2. A sidebar pushed off a phone screen A flex item will not shrink below its content (min-width: auto); the flex overlay shows an item wider than its share min-inline-size: 0 on the item, and a scrolling box for the table
3. z-index does nothing The box is static and not a flex or grid item; the declaration is pale, with a hint, in Chrome and Firefox position: relative
4. A fixed header scrolls away An ancestor’s transform makes it the containing block of fixed boxes; look for it in Styles or Computed Remove the transform once the animation ends, or move the header out
5. A query container shrinks to nothing container-type: inline-size on a box sized by its content; Chrome’s container badge, a computed width of 0 px Give it a size that does not come from its content, such as flex: 1
6. height: 100% does nothing The parent’s height is auto, since a minimum height is not a height; the parent’s computed height is its content’s Let grid or flex stretch the child instead
7. A margin vanishes var(--space)px is a number and a word, not a length; Styles shows nothing wrong, Computed shows 0px calc(var(--space) * 1px)
8. The page scrolls sideways with nothing visible past the edge A closed menu, absolutely positioned and moved off screen; the overflow finder, or Firefox’s overflow badge position: fixed for the panel

Every engine gave the same numbers, before and after:

# Before the fix After the fix
1 heading 0 px below the card’s top, card 60 px tall heading 24 px below the top, card 84 px tall
2 main column 480 px wide, sidebar 232 px past the 360 px layout main column 248 px, sidebar inside
3 the photo is on top where the two overlap the badge is on top
4 header top at −400 px after a 400 px scroll header top at 0 px
5 container 0 px wide, its query never matches container 272 px wide, the query matches
6 sidebar 24 px tall in a 200 px panel sidebar 200 px tall
7 computed margin-top: 0px computed margin-top: 24px
8 page 1,080 px wide in an 800 px viewport page 800 px wide

Three of the signals deserve a closer look, because they are the ones people miss.

Bug 3 is a declaration that is inactive: valid, winning, and without effect on this kind of box. z-index applies to positioned boxes, and also to flex and grid items, which honour it even when they are static. Chrome and Firefox draw such a declaration pale with an information icon, and hovering the icon says why. Web Inspector’s documentation describes no such hint for Safari, so there the properties a box can use are something you have to know, which is what the exercise below practises.

Bug 5 is the “missing container size”. A query container with container-type: inline-size is laid out as if it were empty in the inline direction, so it needs a width that does not depend on what is inside it. A flex item with the default flex: 0 1 auto takes its width from its content, which the containment ignores, so it shrinks to 0 px and its query never matches. container-type: size has the same problem in both directions: give such a container a definite height as well.

Bug 7 is invalid at computed-value time. When the browser parses margin-top: var(--space)px, it cannot know what --space will hold, so it accepts the declaration and the Styles pane shows it as valid. Only when the value is computed does it become the number 24 followed by the word px, which is not a length. The property then falls back to its initial value, 0, as if it had been set to unset, and the paragraph also loses the 16 px margin the browser’s own stylesheet gave it. The Computed pane is the only place this shows.

When nothing points to the cause: bisect

If the loop gives you no lead, stop guessing and halve the problem. Switch off half of the rules (untick them in the Styles pane), or half of the stylesheets: Firefox’s Style Editor has an eye icon beside each sheet, and in Chrome’s Network panel you can right-click a stylesheet’s request, choose Block request and reload. If the bug is still there, the cause is in the half that is still on; if it has gone, it is in the other half. Repeat with that half. This program plays the browser, to show how quickly it narrows down:

Bisecting 1,000 rules JavaScript · bisect/bisect.mjs
// Bisecting a stylesheet: keep half of the rules, check whether the bug is still there, and carry on with the half
// that still shows it. Here a function plays the browser: hasBug(rules) says whether a page styled with only those
// rules shows the bug. In real work you switch rules off in DevTools, or comment them out, and look.
const rules = Array.from({ length: 1000 }, (_, i) => i + 1); // rule numbers 1 to 1000
const span = (list) => (list.length === 1 ? `rule ${list[0]}` : `rules ${list[0]}-${list[list.length - 1]}`);

// Finds one rule that causes the bug on its own, given rules that are known to show it. Returns null when neither
// half shows it on its own: then the bug needs rules from both halves.
function bisect(list, hasBug, log, keepOn = []) {
  let suspects = list;
  let steps = 0;
  while (suspects.length > 1) {
    const half = Math.ceil(suspects.length / 2);
    const first = suspects.slice(0, half);
    const second = suspects.slice(half);
    steps++;
    if (hasBug([...keepOn, ...first])) suspects = first;
    else if (hasBug([...keepOn, ...second])) suspects = second;
    else {
      log(`  step ${steps}: neither ${span(first)} nor ${span(second)} shows the bug on its own`);
      return { steps, rule: null, first, second };
    }
    log(`  step ${steps}: the bug is still there with only ${span(suspects)}`);
  }
  return { steps, rule: suspects[0] };
}

// Counts how often the page is checked: a step needs one check, or two when the first half is clean.
let checks = 0;
const counted = (test) => (on) => (checks++, test(on));

// Case 1: one rule is enough to cause the bug (say, rule 613 sets width: 100vw).
console.log('Case 1: one rule causes the bug');
const one = bisect(rules, counted((on) => on.includes(613)), console.log);
console.log(`Found rule ${one.rule} in ${one.steps} steps (${checks} checks), out of ${rules.length} rules.`);

// Case 2: two rules cause it only together (rule 120 sets display: flex, rule 840 a min-width on the items).
console.log('Case 2: two rules cause the bug together');
checks = 0;
const both = counted((on) => on.includes(120) && on.includes(840));
const quiet = () => {};
const split = bisect(rules, both, console.log);
// Keep one half switched on while bisecting the other, then the other way round.
const inSecond = bisect(split.second, both, quiet, split.first);
const inFirst = bisect(split.first, both, quiet, [inSecond.rule]);
const total = split.steps + inSecond.steps + inFirst.steps;
console.log(`  with ${span(split.first)} kept on, the second half narrows to rule ${inSecond.rule} in ${inSecond.steps} steps`);
console.log(`  with rule ${inSecond.rule} kept on, the first half narrows to rule ${inFirst.rule} in ${inFirst.steps} steps`);
console.log(`Found rules ${inFirst.rule} and ${inSecond.rule} in ${total} steps (${checks} checks).`);

Output

Case 1: one rule causes the bug
  step 1: the bug is still there with only rules 501-1000
  step 2: the bug is still there with only rules 501-750
  step 3: the bug is still there with only rules 501-625
  step 4: the bug is still there with only rules 564-625
  step 5: the bug is still there with only rules 595-625
  step 6: the bug is still there with only rules 611-625
  step 7: the bug is still there with only rules 611-618
  step 8: the bug is still there with only rules 611-614
  step 9: the bug is still there with only rules 613-614
  step 10: the bug is still there with only rule 613
Found rule 613 in 10 steps (15 checks), out of 1000 rules.
Case 2: two rules cause the bug together
  step 1: neither rules 1-500 nor rules 501-1000 shows the bug on its own
  with rules 1-500 kept on, the second half narrows to rule 840 in 9 steps
  with rule 840 kept on, the first half narrows to rule 120 in 9 steps
Found rules 120 and 840 in 19 steps (30 checks).

Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node bisect.mjs

Ten halvings find one rule among 1,000, because 2 to the power 10 is 1,024; each halving takes one check, or two when the first half turned out to be clean. The second case is the trap: when the bug needs two rules together, such as display: flex in one file and a minimum width in another, neither half shows it on its own. Keep one half switched on while you bisect the other, then swap, and both rules turn up in 19 halvings.

The same method works on markup (delete half of the elements of a saved copy of the page) and on history. When a layout worked last week and the branch since then touched 40 files, git bisect checks out the commit halfway between a good and a bad one, you check the page and say good or bad, and Git halves the range again.

Before you call it a browser bug

When a property seems to do the wrong thing, the browser is rarely at fault, but sometimes it is. Before filing a report, check four things.

  • Support. Look up the feature’s Baseline status and its support in each engine, as the Baseline lesson shows. A feature with limited availability may simply not exist yet in the browser you are testing.
  • Agreement. Try the page in the other engines. If Chromium, Firefox and WebKit all draw the same thing, as they did for every bug in this lesson, the CSS is almost certainly saying something other than what you meant.
  • Reduction. Cut the page down to the smallest file that still shows the problem. WebKit’s guide to test case reduction describes the method: save a copy of the page, remove one element or rule at a time, reload, and put back whatever made the bug disappear. Reducing often finds the cause before you ever send the report.
  • The trackers. Search the engine’s tracker for the problem first, then file the reduced file, the browser version and what you expected against what you saw: the Chromium issue tracker, Mozilla’s Bugzilla for Firefox, and WebKit’s Bugzilla for Safari. Chromium’s guidelines also ask you to check the latest version and say how other browsers behave.
HTML, CSS & JS Playground (Online Editor) Paste a reduced test case into its panes to check it in the browsers you have, then share it with a bug report.

Practise: why is this declaration inactive?

A declaration that is valid and still does nothing is the hardest kind of bug to see, and only some browsers point it out. The exercise turns the rules behind Chrome’s and Firefox’s hints into a function: z-index and the offset properties need a positioned box, item properties need the right parent, container properties need the right display, and a flex or grid parent turns inline children into block-level boxes.

Exercise · Medium · JavaScript

Say why a declaration has no effect on a box

A declaration can be valid and win the cascade, and still change nothing, because the property only applies to some kinds of box. Chrome and Firefox show such a declaration pale, with a hint beside it. Write the same check in inactive.mjs: a function whyInactive(property, box), exported, that returns why property has no effect on box, or null when it can take effect.

box is { display, position, parent, replaced }: its own display, its position (default 'static'), the display of its parent (default 'block'), and replaced (default false), which is true for an image or a form control. Return the first of these reasons that applies:

- 'needs-position': top, right, bottom, left or inset on a box whose position is 'static'. The same for z-index, unless the box is a flex or grid item: its parent's display is flex, inline-flex, grid or inline-grid. - 'needs-flex-parent': flex, flex-grow, flex-shrink or flex-basis, when the parent is not a flex container. - 'needs-grid-parent': grid-column, grid-row or grid-area, when the parent is not a grid container. - 'needs-flex-container': flex-direction, flex-wrap or flex-flow, when the box itself is not a flex container. - 'needs-grid-container': grid-template-columns, grid-template-rows, grid-template-areas or grid-auto-flow, when the box itself is not a grid container. - 'needs-flex-or-grid-container': justify-content, align-items, gap, row-gap or column-gap, when the box is neither a flex nor a grid container. (None of the boxes has columns.) - 'inline-box': width or height on a box whose display is 'inline', unless it is replaced, or its parent is a flex or grid container, which makes the box block-level. - 'block-level-box': vertical-align on a box that is neither inline-level (inline, inline-block, inline-flex or inline-grid) nor a table-cell. A child of a flex or grid container is block-level whatever its own display. - 'float-ignored': float on a flex or grid item, or on a box whose position is absolute or fixed.

Every other property, and every case not listed, returns null. The sample tests import the function from inactive.mjs and run in your browser.

Starter code · inactive.mjs

/** Why a declaration has no effect on a box, or null when it can take effect. */
export function whyInactive(property, box) {
  // Replace this line with your code.
  return null;
}
The sample tests · inactive.test.mjs
import { test, assert } from 'mysmartcopilot:test';
import { whyInactive } from './inactive.mjs';

const block = { display: 'block' };

test('offsets and z-index need a positioned box', () => {
  assert.equal(whyInactive('top', block), 'needs-position');
  assert.equal(whyInactive('inset', { display: 'block', position: 'relative' }), null);
  assert.equal(whyInactive('left', { display: 'block', position: 'sticky' }), null);
  assert.equal(whyInactive('z-index', block), 'needs-position');
  assert.equal(whyInactive('z-index', { display: 'block', position: 'absolute' }), null);
});

test('z-index works on a static flex or grid item, offsets do not', () => {
  assert.equal(whyInactive('z-index', { display: 'block', parent: 'flex' }), null);
  assert.equal(whyInactive('z-index', { display: 'block', parent: 'inline-grid' }), null);
  assert.equal(whyInactive('top', { display: 'block', parent: 'grid' }), 'needs-position');
});

test('item properties need the right parent', () => {
  assert.equal(whyInactive('flex-grow', block), 'needs-flex-parent');
  assert.equal(whyInactive('flex', { display: 'block', parent: 'grid' }), 'needs-flex-parent');
  assert.equal(whyInactive('flex-basis', { display: 'block', parent: 'inline-flex' }), null);
  assert.equal(whyInactive('grid-column', { display: 'block', parent: 'flex' }), 'needs-grid-parent');
  assert.equal(whyInactive('grid-area', { display: 'block', parent: 'grid' }), null);
});

test('container properties need the right display on the box itself', () => {
  assert.equal(whyInactive('flex-wrap', { display: 'grid' }), 'needs-flex-container');
  assert.equal(whyInactive('flex-direction', { display: 'inline-flex' }), null);
  assert.equal(whyInactive('grid-template-columns', { display: 'flex' }), 'needs-grid-container');
  assert.equal(whyInactive('justify-content', block), 'needs-flex-or-grid-container');
  assert.equal(whyInactive('align-items', { display: 'inline-block' }), 'needs-flex-or-grid-container');
  assert.equal(whyInactive('gap', { display: 'inline-grid' }), null);
  assert.equal(whyInactive('justify-content', { display: 'block', parent: 'flex' }), 'needs-flex-or-grid-container');
});

test('width and height skip non-replaced inline boxes, unless a flex or grid parent blockifies them', () => {
  assert.equal(whyInactive('width', { display: 'inline' }), 'inline-box');
  assert.equal(whyInactive('height', { display: 'inline', replaced: true }), null);
  assert.equal(whyInactive('width', { display: 'inline', parent: 'flex' }), null);
  assert.equal(whyInactive('width', { display: 'inline-block' }), null);
});

test('vertical-align needs an inline-level box or a table cell', () => {
  assert.equal(whyInactive('vertical-align', block), 'block-level-box');
  assert.equal(whyInactive('vertical-align', { display: 'inline-block' }), null);
  assert.equal(whyInactive('vertical-align', { display: 'table-cell' }), null);
  assert.equal(whyInactive('vertical-align', { display: 'inline-block', parent: 'flex' }), 'block-level-box');
});

test('float is ignored on flex and grid items and on absolutely positioned boxes', () => {
  assert.equal(whyInactive('float', block), null);
  assert.equal(whyInactive('float', { display: 'block', parent: 'grid' }), 'float-ignored');
  assert.equal(whyInactive('float', { display: 'block', position: 'fixed' }), 'float-ignored');
  assert.equal(whyInactive('float', { display: 'block', position: 'relative' }), null);
});

test('everything else can take effect', () => {
  assert.equal(whyInactive('color', { display: 'inline' }), null);
  assert.equal(whyInactive('align-content', block), null);
  assert.equal(whyInactive('margin-top', block), null);
});
A hint

Work out two facts about the box before you look at the property: is its parent a flex or grid container (then the box is a flex or grid item, and block-level), and is it positioned (any position except static)? Then keep each list of properties in a Set and check them in the order of the prompt, returning as soon as one of them holds the property.

The sample tests run on this device, in your browser (QuickJS): nothing is sent to mysmartcopilot.com. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.

Check yourself

8 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.

  1. Question 1 of 8 Put the steps of the debugging loop in order, for a bug that the Styles pane does not explain at once.

    Give each item its position, from 1 (first).

    Show the answer to question 1

    Answer:

    1. Reproduce it at one width in one browser
    2. Read the Styles pane for the element
    3. Read the computed value
    4. Check the parent's layout mode
    5. Bisect the rules, then the markup

    Reproducing first makes every later check repeatable. The Styles pane says which declarations matched, lost, failed to parse or have no effect; the computed value says what the browser used; the parent's layout mode explains most of what remains. Bisecting is the fallback when those steps give no lead, because it finds the cause without a theory.

  2. Question 2 of 8 A card's heading has a 24px top margin. The gap appears above the card's background instead of inside it, and the heading touches the card's top edge. What is happening?

    Choose one answer.

    Show the answer to question 2

    Answer: The heading's margin collapses with the card's, so it sits outside the card; display: flow-root on the card keeps it inside

    With no padding or border between them, the top margins of a block and its first child collapse into one margin outside the parent. In the lesson's run the heading was 0px below the card's top edge before the fix and 24px after display: flow-root, which starts a new block formatting context, in all three engines.

  3. Question 3 of 8 A badge has z-index: 2 and position: static; it is a block in normal flow. A photo with position: relative overlaps it. Which is on top, and why?

    Choose one answer.

    Show the answer to question 3

    Answer: The photo, because z-index has no effect on a static box that is not a flex or grid item

    z-index applies to positioned boxes, and to flex and grid items even when they are static. A static block's z-index is inactive, and Chrome and Firefox show it pale with a hint. Positioned boxes paint after the blocks in normal flow, so the photo covers the badge until the badge gets position: relative.

  4. Question 4 of 8 A header with position fixed scrolls away with the page. What do you check first?

    Choose one answer.

    Show the answer to question 4

    Answer: Its ancestors, for a transform, a filter or another property that makes an ancestor the containing block of fixed boxes

    A fixed box is placed relative to the viewport only while no ancestor has a transform, a filter, will-change: transform or containment that makes it the fixed box's containing block. In the lesson's run, a transform: translateX(0) left behind by an animation put the header 400px above the screen after a 400px scroll; with transform: none it stayed at 0px.

  5. Question 5 of 8 A 360px phone page scrolls sideways. Which of these can never be the cause?

    Choose every answer that is right.

    Show the answer to question 5

    Answer:

    • A closed menu with position fixed, moved off screen with a transform
    • A table 600px wide inside a box with overflow-x auto

    A box that scrolls or clips sideways holds the overflow of what is inside it, and a fixed box belongs to the viewport, so neither makes the page wider. In the lesson's runs the absolutely positioned menu made a page 1080px wide in an 800px viewport, and with position fixed it was 800px. The banner, the long code and wide images all showed up in the overflow finder's list.

  6. Question 6 of 8 The stylesheet says :root { --space: 24; } and .note { margin-top: var(--space)px; }. What is the computed margin-top of .note?

    Choose one answer.

    Show the answer to question 6

    Answer: 0px

    var() is replaced token by token, so the value becomes the number 24 followed by the word px: not a length. The declaration was valid when it was parsed, so the Styles pane shows nothing wrong, but it is invalid when the value is computed, and margin-top, which is not inherited, falls back to its initial value, 0. All three engines computed 0px, and 24px with calc(var(--space) * 1px). On a paragraph it also throws away the browser's default margin of 16px, which is often how the bug is noticed.

  7. Question 7 of 8 Your site's CSS uses no cascade layers. You add @layer debug { .title { color: red; } } at the end of the last stylesheet. The title stays grey. Why?

    Choose one answer.

    Show the answer to question 7

    Answer: Unlayered declarations beat every layered one, whatever the order

    Unlayered styles count as one implicit layer that comes after every named layer, so for normal declarations they win. In the lesson's run the debug rule won only when the site's own rules were in layers too, or with !important inside the debug layer, which then lost only to an !important in an earlier layer.

  8. Question 8 of 8 A stylesheet has 1,000 rules and one of them causes the bug. Each step keeps the half that still shows it. How many steps does it take to get down to one rule?

    Type a number.

    Show the answer to question 8

    Answer: 10

    Each step halves the suspects: 1,000, 500, 250, 125, 63, 32, 16, 8, 4, 2 and finally 1, ten halvings, because 2 to the power 10 is 1,024. The lesson's program found rule 613 in 10 steps. When the bug needs two rules together, neither half shows it alone, and the search takes about twice as many steps.

Interview questions

Fresher · Why do people debug layouts with outline instead of border?

An outline is drawn outside the element’s border and takes up no space, so adding one to every element shows where each box is without moving anything. A border is part of the box: with the default box-sizing: content-box it makes every element wider and taller, which can make items wrap, push content down or change the very gap you are investigating. In a row of three 120 px boxes in a 360 px container, a 1 px outline left the row on one line in Chromium, Firefox and WebKit, while a 1 px border made each box 122 px wide and the row wrapped onto a second line. Outlines also do not affect scrolling or overflow, so they are safe to leave on while you scroll through the page.

Key takeaways

  • Debug in a loop: reproduce at one width, read the Styles pane, read the computed value, check the parent’s layout mode, change one thing, and bisect when there is no lead.
  • Crossed out means another declaration won; a warning means the parser dropped it; pale with an icon means valid but inactive for this kind of box.
  • Outline boxes to see them: outlines take no space, borders change the layout.
  • A debug layer wins only over other layers; unlayered styles beat every layer for normal declarations.
  • To find what scrolls a page sideways, list the elements and texts that end past the right edge, outside any box that scrolls or clips sideways.
  • Halving finds one rule among 1,000 in 10 steps; when two rules cause a bug together, keep one half on while you bisect the other.
  • Before blaming a browser, check support, compare engines and reduce the test case.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.