Debugging and review

Bisect a CSS problem

Isolate a difficult styling bug by disabling half the possible causes, repeating the test, and reducing the page to a minimal reproduction.

Some bugs don’t give up to the Computed panel. A sticky header stops sticking after a redesign. Nothing in the Styles panel looks wrong, and 2,000 lines of CSS could be responsible.

When you can’t reason your way to the cause, stop reasoning and bisect. Cut the possible causes in half, test, and repeat. It’s git bisect applied to stylesheets.

The loop

Say the bug is that sticky header.

  1. Disable half of the stylesheets, or half of the rules in one big file. In DevTools, uncheck the declarations, or comment out a block in the source.
  2. Reload. Is the header still broken?
  3. If yes, the cause is in the half you left enabled. If no, it’s in the half you disabled.
  4. Take the guilty half, split it again, and go back to step 2.

Each round halves the search space. Ten rounds cover a thousand rules. In practice five or six get you to a handful of declarations.

For layout bugs, I often bisect the HTML instead. Delete half the wrappers around the broken element. If the bug survives, they were innocent.

Build a minimal reproduction

Once you’ve cornered the cause, shrink the page around it. Copy the broken element and its ancestors into a blank HTML file with only the suspicious CSS. Remove one thing at a time and check the bug survives each removal.

Stop when removing anything else makes the bug go away. What’s left is the minimal reproduction: the smallest HTML and CSS that show the problem.

At this point the answer is usually staring at you. With the sticky header, the reduced case is a header inside a wrapper with overflow: hidden. The header sticks to that wrapper, not to the page, and the wrapper scrolls away.

What bisecting finds

These bugs are almost always interactions between rules that are fine on their own: a transform on an ancestor that became the containing block for a fixed child, a flex item’s automatic minimum pushing a row too wide, an opacity creating a stacking context that traps a z-index, a custom property redefined three components up.

You’d never spot these by reading the CSS for the broken element. They live on its ancestors.

Keep the reduction

Don’t throw the minimal reproduction away. Save it next to the component, or paste it into a comment above the fix. When the bug comes back after a refactor, you have a five-line test case.

Try it on the course page: add transform: translateZ(0) to a random wrapper and watch a position: fixed element inside it stop working. Now pretend you don’t know what you did, and bisect back to it.

Lesson completed