Learn to test and debug CSS by breaking it on purpose. Ten copy-and-run examples covering outlines, computed styles, the box model, margin collapse, bisecting, breakpoints, runtime styles, and Stylelint.
The CSS testing and debugging article covers the systematic side of a language that mostly gets practised by guesswork: manual and responsive checks, automated linting and regression testing, and the dev tools you reach for when a layout goes wrong. Reading about those tools is not the same as having used them on a bug, so every example here starts from a page that is genuinely broken, shows the diagnostic move that finds the cause, and then fixes it. This workbook works through the whole range: outlining every box, reading computed styles to see which rule won, the box model overflow, the margin that escapes its parent, the gap under an image, bisecting a stylesheet, breakpoint boundaries, styles that change at runtime, and linting that catches mistakes before the browser does. The idea that ties it together arrives in the last example, and it turns nine separate tricks into one short checklist.
1. Make the invisible visible
Most layout bugs are invisible because boxes are invisible. Drawing an outline around every element on the page is the cheapest diagnostic there is, and the one worth trying first.
<html lang="en"><head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Outline everything</title> <link rel="stylesheet" href="styles.css"></head><body class="debug"> <nav class="nav"> <a class="nav__link" href="#">Home</a> <a class="nav__link nav__link--current" href="#">Guides</a> <a class="nav__link" href="#">Cheatsheets</a> </nav></body></html>
body { margin: 0; padding: 24px; font-family: "Segoe UI", sans-serif; background: #f7f9fc; }.nav { display: flex; gap: 8px; }.nav__link { padding: 10px 16px; color: #6f7580; text-decoration: none;}.nav__link--current { padding: 12px 16px; /* the culprit: taller than its siblings */ background: #1e2331; color: #fff;}/* Debug layer: outline, not border, so nothing moves */.debug * { outline: 1px solid rgba(255, 0, 0, 0.35); }
Three menu items that looked almost right, and now a red rectangle around the middle one that is visibly taller than the other two. The reason to use outline rather than border is the entire trick: outlines are drawn outside the box without occupying space, so the page you are inspecting is the page you were debugging rather than a version shifted by three pixels in every direction. Hanging the rule off a .debug class on <body> rather than applying it globally means you can switch it on and off from the Elements panel by toggling one class, which is far quicker than commenting the rule in and out. Remember to take the class off before you commit.
2. Which rule actually won
When two rules disagree, guessing is slow. The computed styles view answers it directly, and getComputedStyle gives you the same answer from the console.
<html lang="en"><head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Computed styles</title> <link rel="stylesheet" href="styles.css"></head><body> <div class="panel"> <button class="btn btn--ghost">Why am I still navy?</button> </div> <script> const btn = document.querySelector('.btn--ghost'); // what the element is actually rendering console.log('computed:', getComputedStyle(btn).backgroundColor); // what the inline style says, which is usually nothing console.log('inline: ', btn.style.backgroundColor === '' ? '(empty)' : btn.style.backgroundColor); </script></body></html>
body { margin: 0; padding: 24px; font-family: "Segoe UI", sans-serif; background: #f7f9fc; }.panel { background: #fff; border: 1px solid #e2e6ef; border-radius: 8px; padding: 24px; max-width: 420px; }.panel .btn { background: #1e2331; color: #fff; } /* two classes: 0-2-0 */.btn { border: 2px solid #1e2331; padding: 12px 20px; border-radius: 6px; font: inherit; cursor: pointer;}.btn--ghost { background: transparent; color: #1e2331; } /* one class: 0-1-0, loses */
A ghost button that is not ghostly, and a console reading computed: rgb(30, 35, 49) next to inline: (empty). Those two lines are the whole lesson. The inline style is empty because nobody set one, so inspecting element.style tells you nothing about what is rendering; the computed value is what the browser resolved after the cascade, inheritance and every stylesheet had their say. Open the Elements panel on this button and the same story appears visually, with .btn--ghost‘s background struck through and .panel .btn winning above it, because a descendant selector carrying two classes outweighs a single class no matter which came later in the file. Flattening that selector to one class is the fix, which is the argument the BEM article makes at length.
3. The box model bug
A row of three columns at 33.33 percent that refuses to fit on one line is not a flexbox mystery. It is padding and borders being added on top of the width you asked for.
<html lang="en"><head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Box model</title> <link rel="stylesheet" href="styles.css"></head><body> <div class="row"> <div class="col">One</div> <div class="col">Two</div> <div class="col">Three</div> </div> <script> const col = document.querySelector('.col'); console.log('asked for 33.33% of', col.parentElement.clientWidth, 'px'); console.log('actually occupies ', col.getBoundingClientRect().width, 'px'); </script></body></html>
body { margin: 0; padding: 24px; font-family: "Segoe UI", sans-serif; background: #f7f9fc; }.row { display: flex; width: 600px; background: #e2e6ef; }.col { width: 33.333%; /* 200px of a 600px row */ padding: 16px; /* plus 32px */ border: 2px solid #1e2331; /* plus 4px, so 236px each */ background: #fff;}/* The fix, and the line most stylesheets open with *//* *, *::before, *::after { box-sizing: border-box; } */
Three columns squeezed narrower than they should be, or spilling past the row depending on your flex settings, and a console line reporting a rendered width that does not match the one you wrote. Under the default content-box sizing, widthdescribes the content area only, so padding and borders are added outside it and each column really wants 236 pixels rather than 200. The box model diagram in the dev tools shows this as nested rectangles with the numbers on them, which is usually faster than arithmetic: content, then padding, then border, then margin, each labelled. Uncomment the box-sizingrule and width starts meaning the whole visible box, which is why that one-line reset appears at the top of almost every modern stylesheet.
4. The margin that came from nowhere
A gap appears above a section, and no element seems to own it. This is margin collapse, and it looks like a bug until you can see it happening.
<html lang="en"><head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Collapsing margins</title> <link rel="stylesheet" href="styles.css"></head><body class="debug"> <section class="hero"> <h1 class="hero__title">The margin escaped</h1> </section> <section class="hero hero__fixed"> <h1 class="hero__title">Contained by flow-root</h1> </section></body></html>
body { margin: 0; padding: 24px; font-family: "Segoe UI", sans-serif; background: #f7f9fc; }.hero { background: #1e2331; margin-bottom: 24px;}.hero__title { margin: 32px 0; /* this margin leaves the parent */ color: #fff; font-size: 24px;}.hero__fixed { display: flow-root; /* the parent now contains its child's margins */}.debug * { outline: 1px solid rgba(255, 0, 0, 0.35); }
The first navy band hugs its heading with white space pushed outside it, above and below, while the second band has 32 pixels of navy on each side of the text. With the debug outlines on, the difference is unmistakable: in the first case the parent’s red rectangle sits tight against the heading and the gap is outside both boxes, which is exactly what collapse means. A top or bottom margin on the first or last child escapes a parent that has no padding, border, or new formatting context to hold it in, and the fix is to give the parent any one of those. display: flow-root is the modern choice because it establishes the context without adding a pixel of its own, and it is the same value the display article reaches for to contain floats.
5. The gap under an image nobody asked for
A four-pixel strip of background appears below an image inside its container. Nothing in the stylesheet mentions four pixels, which is the clue.
<html lang="en"><head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Image gap</title> <link rel="stylesheet" href="styles.css"></head><body> <figure class="thumb"> <img class="thumb__img" src="https://placehold.co/400x200/1e2331/ffffff" alt="Placeholder"> </figure> <figure class="thumb thumb--fixed"> <img class="thumb__img thumb__img--block" src="https://placehold.co/400x200/1e2331/ffffff" alt="Placeholder"> </figure></body></html>
body { margin: 0; padding: 24px; font-family: "Segoe UI", sans-serif; background: #f7f9fc; }.thumb { margin: 0 0 24px; padding: 0; background: #ff3b30; /* red, so the gap is impossible to miss */ width: 400px;}.thumb__img { width: 100%; }/* The fix: take the image out of the text line */.thumb__img--block { display: block; }
A red sliver under the first image and nothing under the second. The cause is that an img is inline by default, so it sits on a text baseline, and the space reserved below that baseline for letter descenders is what you are seeing in red. Nothing is broken and no rule is misapplied, which is why this one wastes so much time: you go looking for a margin that does not exist. Two fixes work, display: block on the image or vertical-align: middle, and the diagnostic that confirms the diagnosis before you commit to either is setting font-size: 0 on the parent, because a gap that disappears when the font shrinks was always about text.
6. Bisect the stylesheet
When you cannot tell which rule is causing something, stop reading and start halving. Disable half the stylesheet, see if the bug survives, and repeat on whichever half kept it.
<html lang="en"><head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Bisecting</title> <link rel="stylesheet" href="styles.css"></head><body> <article class="card"> <h2 class="card__title">Why is this heading grey?</h2> <p class="card__body">Nothing in the title rule says grey.</p> </article></body></html>
body { margin: 0; padding: 24px; font-family: "Segoe UI", sans-serif; background: #f7f9fc; }/* --- half one --- */.card { background: #fff; border: 1px solid #e2e6ef; border-radius: 8px; padding: 24px; max-width: 420px; }.card__title { margin: 0 0 8px; font-size: 20px; color: #1e2331; }.card__body { margin: 0; line-height: 1.6; }/* --- half two --- */.card > * { max-width: 60ch; }.card * { color: #6f7580 !important; } /* the culprit, six rules from the scene */.card a { text-decoration: underline; }
A heading that renders grey while its own rule clearly asks for navy, and a stylesheet where nothing near the top is to blame. Comment out the second half and the heading turns navy, which halves the search space in one move; comment out half of what remains and you have the rule in two more. The dev tools do the same job faster, since unticking a declaration in the Styles panel disables it live and the strike-through in the computed view would have pointed at the !important immediately, but the bisection habit is what saves you when the problem is a whole block rather than one property, or when the culprit is a stylesheet you did not know was loading. Note what made this hard: a wildcard selector with !important, which beats specificity entirely and is precisely the pattern the BEM article exists to prevent.
7. Test the edges of a breakpoint
Responsive bugs cluster at the exact pixel where a media query changes its mind. Testing at 375 and 1440 will never find them, so test at the boundary itself.
<html lang="en"><head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Breakpoints</title> <link rel="stylesheet" href="styles.css"></head><body> <div class="layout"> <div class="layout__item">One</div> <div class="layout__item">Two</div> </div> <p class="readout"></p> <script> const readout = document.querySelector('.readout'); const report = () => { readout.textContent = `width ${window.innerWidth} ` + `max-768: ${matchMedia('(max-width: 768px)').matches} ` + `min-768: ${matchMedia('(min-width: 768px)').matches}`; }; window.addEventListener('resize', report); report(); </script></body></html>
body { margin: 0; padding: 24px; font-family: "Segoe UI", sans-serif; background: #f7f9fc; }.layout { display: flex; gap: 12px; }.layout__item { flex: 1; background: #1e2331; color: #fff; padding: 24px; }@media (max-width: 768px) { .layout { flex-direction: column; }}@media (min-width: 768px) { /* overlaps by exactly one pixel */ .layout { flex-direction: row; }}.readout { font-family: monospace; color: #6f7580; }
Drag the window to exactly 768 pixels and the readout shows both queries reporting true at the same time. They are not mutually exclusive, because max-width: 768px means up to and including, and min-width: 768px means from 768 upwards, so at that single width both blocks apply and the later one silently wins. On a phone held at that width the layout is whichever rule you happened to write last, which is not a decision you made. The matchMedia readout is worth keeping in a scratch page because it turns an argument about pixels into an observation. The durable fix is to stop using both directions: pick mobile-first, write only min-width queries, and let the base styles handle everything below the first breakpoint.
8. Debugging styles that change at runtime
Styles applied by JavaScript need a different reflex, because the stylesheet you are reading is not the whole story by the time the bug appears.
<html lang="en"><head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Runtime styles</title> <link rel="stylesheet" href="styles.css"></head><body> <button class="btn" id="toggle">Open panel</button> <div class="panel" id="panel">Panel content</div> <script> const panel = document.getElementById('panel'); const toggle = document.getElementById('toggle'); toggle.addEventListener('click', () => { const open = panel.classList.toggle('panel--open'); // the naive check, which is empty until JS sets it once console.log('inline :', panel.style.display || '(empty)'); // the honest check console.log('computed:', getComputedStyle(panel).display); console.log('classes :', panel.className); toggle.textContent = open ? 'Close panel' : 'Open panel'; }); </script></body></html>
body { margin: 0; padding: 24px; font-family: "Segoe UI", sans-serif; background: #f7f9fc; }.btn { border: none; background: #1e2331; color: #fff; padding: 12px 20px; border-radius: 6px; font: inherit; cursor: pointer; }.panel { display: none; } /* set in CSS, so element.style is empty */.panel--open { display: block; margin-top: 16px; background: #fff; border: 1px solid #e2e6ef; border-radius: 8px; padding: 24px; }
Every click logs inline: (empty) alongside a computed value that flips between none and block. That mismatch is the trap: code written as if (panel.style.display === 'none') fails on the very first click, because the initial none came from the stylesheet and inline styles are a different, empty place. getComputedStyle asks the browser what is actually being applied right now, which is the only reliable question at runtime. Logging className alongside it is worth the extra line, since it tells you whether the bug is the class not being applied or the CSS for that class not doing what you thought. If your CSS is compiled from Sass, turn source maps on as well, so the dev tools point at the partial you wrote rather than the concatenated output.
9. Catch it before the browser
A linter reads your stylesheet the way a browser does, without the patience. Stylelint runs in a second and finds the class of mistake that never announces itself visually.
// .stylelintrc.json{ "extends": "stylelint-config-standard"}
/* styles.css */.card { colour: #1e2331; padding: 24px border-radius: 8px;}.card { background: #FFF;}.card__title { font-size: 20PX; }
npm install --save-dev stylelint stylelint-config-standardnpx stylelint styles.css
styles.css 3:16 ✖ Missed semicolon CssSyntaxError✖ 1 problem (1 error, 0 warnings)
One error, not four, because a missing semicolon stops the parser and everything after it goes unread. Fix that one line, run it again, and the real report appears:
styles.css 2:3 ✖ Unknown property "colour" property-no-unknown 7:1 ✖ Duplicate selector ".card", first used at line 1 no-duplicate-selectors 11:1 ✖ Expected class selector ".card__title" to be kebab-case selector-class-pattern
Three problems the browser would have shown you as nothing at all: a British spelling the parser silently discards, a duplicated selector splitting one component across two blocks, and a naming complaint that is a false positive here, because the standard config does not know about BEM. That last one is the useful lesson about linters, which is that a rule you disagree with is a rule to configure rather than a reason to stop linting:
// .stylelintrc.json{ "extends": "stylelint-config-standard", "rules": { "selector-class-pattern": "^[a-z][a-z0-9]*(-[a-z0-9]+)*(__[a-z][a-z0-9]*(-[a-z0-9]+)*)?(--[a-z][a-z0-9]*(-[a-z0-9]+)*)?$" }}
With that pattern in place .card__title--muted passes and .cardTitle still fails, so the convention from the BEM article becomes something the machine enforces rather than something you remember. Note that Stylelint deliberately stays out of formatting, so hex casing and unit casing are left to a formatter like Prettier, and that a non-zero exit code is what makes this useful in CI. Visual regression tools such as BackstopJS or Percy are the same idea applied to rendering rather than syntax: they screenshot your pages and fail the build when pixels move, which is Example 1’s eyeball check running automatically on every commit.
10. A debug stylesheet worth keeping, and the model
The tricks above are more useful as a file you keep than as things you remember. Here is a small one, and the checklist that decides which trick to reach for.
<html lang="en"><head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Debug toolkit</title> <link rel="stylesheet" href="styles.css"> <link rel="stylesheet" href="debug.css"></head><body class="debug"> <article class="card"> <img class="card__img" src="https://placehold.co/400x120/1e2331/ffffff"> <h2 class="card__title">Flagged by the toolkit</h2> <a class="card__link">A link with no href</a> <div class="card__spacer"></div> </article></body></html>
/* debug.css: linked last, removed before release *//* 1. Every box, without moving anything */.debug * { outline: 1px solid rgba(255, 0, 0, 0.35); }/* 2. Accessibility and markup mistakes the design will never show you */.debug img:not([alt]) { outline: 3px solid #ff3b30; }.debug a:not([href]) { outline: 3px solid #ff9500; }.debug button:not([type]) { outline: 3px dashed #ff9500; }/* 3. Elements that take up space while containing nothing */.debug *:empty:not(img):not(input):not(br):not(td) { background: rgba(255, 149, 0, 0.25); }
The card renders as normal with three warnings layered on top: a red ring on the image that has no alt, an orange ring on the anchor with no href, and an orange wash over the empty spacer div. None of these are visual bugs, which is the point, since a page can look finished and still be unusable with a screen reader or full of leftover markup. Keep this as a separate file linked last, switched on by one class, and delete the link before you ship.
The model behind all ten is that a CSS bug is always an answer to one of three questions, and knowing which one you are asking tells you which tool to open. Which rule won is a cascade question, and the computed styles view answers it: Examples 2, 6 and 9 are all this question, and the answer is nearly always specificity, source order, or !important. What is this box is a geometry question, and outlines and the box model diagram answer it: Examples 1, 3, 4, 5 and 10 live here, where the rule is applying exactly as written and the surprise is what “width” or “margin” means. When does it apply is a state question, answered by the responsive tools and getComputedStyle: Examples 7 and 8 are this, where the CSS is correct at one viewport or one moment and wrong at another. Asking which of the three you have before touching anything is what separates debugging from redecorating, because each question has a different first move and trying them in the wrong order is how an afternoon disappears.
Work through these and you have the article in practice: outlines that make boxes visible without moving them; computed styles that name the winning rule; the box model overflow and the box-sizing line that ends it; margins escaping a parent that has nothing to hold them; the baseline gap under an inline image; bisecting a stylesheet when reading it fails; breakpoints that overlap at exactly one pixel; runtime styles read honestly rather than from element.style; a linter that catches what the browser silently swallows; and a debug stylesheet plus the three questions that route every future bug to the right tool. The habit that follows is a diagnostic one: before changing a single declaration, decide whether you are looking at a cascade problem, a geometry problem, or a state problem, because a fix aimed at the wrong one of those is how !important gets into a stylesheet.
Thanks for reading, Andrei.
[…] Testing and Debugging CSS: 10 Code-Along Examples […]
[…] Testing and Debugging CSS: 10 Code-Along Examples […]
[…] For the full background, read the guide to testing and debugging CSS. To practise, work through the 10 code-along examples. […]