CSSCommon CSS Bugs & Fixes

Common CSS Bugs & Fixes

Certain CSS bugs come up again and again, in every codebase, regardless of experience level — not because CSS is badly designed, but because a handful of its rules are genuinely surprising the first time you hit them. This page is a field guide: the symptom, the actual cause, and the fix.

1. Margin collapse

Symptom: two stacked block elements with margin-bottom: 20px and margin-top: 20px end up with only 20px of gap between them, not 40px.

CSS
.box-a {
  margin-bottom: 20px;
}

.box-b {
  margin-top: 20px;
}
/* Gap between them is 20px (the larger margin wins), not 40px */
Why this happens
Adjacent vertical margins between sibling block elements "collapse" into a single margin equal to the larger of the two. This is intentional spec behavior, meant to keep vertical rhythm consistent when authors stack elements with their own top/bottom spacing.

CSS
/* Fix: use gap on the parent instead of margins on children */
.stack {
  display: flex;
  flex-direction: column;
  gap: 20px; /* gap never collapses */
}
2. Flex item overflow (min-width: auto)

Symptom: a flex child containing long unbroken text (a URL, a filename) overflows its container and breaks the layout, ignoring flex-shrink.

CSS
.row {
  display: flex;
  gap: 12px;
}

.row .text-col {
  flex: 1;
  /* Still overflows with a long unbreakable string! */
}
Why this happens
Flex items have an implicit min-width: auto, which means a flex item will never shrink smaller than its content's intrinsic minimum size — and for unbroken text, that minimum is the width of the entire string.

CSS
.row .text-col {
  flex: 1;
  min-width: 0;       /* overrides the auto minimum */
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
3. z-index not working

Symptom: setting a huge z-index (9999) still doesn't put an element on top of another one.

CSS
.tooltip {
  position: absolute;
  z-index: 9999; /* still hidden behind .modal! */
}

.modal {
  position: fixed;
  z-index: 10;
}
Why this happens
z-index only compares elements within the same stacking context. If .tooltip's ancestor has its own stacking context (created by transform, opacity < 1, filter, will-change, or its own z-index on a positioned element), the tooltip's z-index of 9999 is only compared against siblings inside that context — it never gets to compete directly with .modal.

CSS
/* Fix: render the tooltip outside the stacking-context-creating
   ancestor (e.g. via a React portal), or remove the property on the
   ancestor that creates the unwanted context */
Diagnosing this
In DevTools, check every ancestor of the misbehaving element for transform, opacity, filter, or position + z-index — any one of those creates a new stacking context and traps your z-index inside it.
4. 100vh on mobile

Symptom: a section styled with height: 100vh is taller than the visible mobile screen, and the bottom gets cut off behind the browser's address bar.

CSS
.hero {
  height: 100vh; /* includes space behind the collapsing address bar */
}
Why this happens
Mobile browsers show/hide their address bar as you scroll, and vh is defined against the *largest possible* viewport, not the currently visible one — so 100vh is often taller than what is actually on screen.

CSS
.hero {
  height: 100dvh; /* dynamic viewport height: tracks the real visible area */
}

/* Fallback for older browsers that lack dvh */
.hero {
  height: 100vh;
  height: 100dvh;
}
5. The mystery inline-image gap

Symptom: a small unexplained gap of a few pixels appears under an image inside a div, even though there is no margin or padding set anywhere.

CSS
.frame img {
  /* No margin, no padding -- yet there's a ~4px gap below the image */
}
Why this happens
img is an inline element by default, and inline elements are laid out on a text baseline — the gap is space reserved for descenders (like the tail of a letter "g") in the surrounding line box.

CSS
.frame img {
  display: block; /* removes it from the inline/text layout entirely */
}
/* or: vertical-align: bottom / middle also removes the gap */
6. position: fixed stuck inside a transform

Symptom: an element with position: fixed is supposed to stay pinned to the viewport when scrolling, but instead it scrolls away with the page.

Why this happens
A fixed element positions itself relative to its nearest ancestor that creates a containing block for fixed elements — and any ancestor with transform, filter, perspective, or will-change: transform set creates exactly that. Once trapped, the "fixed" element behaves like it is absolutely positioned inside that ancestor instead of the viewport.

CSS
/* Fix: remove the transform from the ancestor, or move the fixed
   element outside of it in the DOM (e.g. render via a portal) */
7. border-box vs content-box surprises

Symptom: an element styled with width: 200px and padding: 20px ends up 240px wide on screen, breaking a layout that assumed 200px.

CSS
.box {
  width: 200px;
  padding: 20px;
  border: 2px solid black;
  /* content-box (the default): rendered width = 200 + 40 + 4 = 244px */
}
Why this happens
The default box-sizing: content-box adds padding and border on top of the declared width instead of inside it. Almost every modern reset switches to border-box globally for exactly this reason.

CSS
*, *::before, *::after {
  box-sizing: border-box; /* width/height now include padding + border */
}
8. Sticky positioning that refuses to stick

Symptom: position: sticky is set correctly, with a top value and everything, but the element just scrolls away like static positioning.

CSS
.sticky-sidebar {
  position: sticky;
  top: 20px;
  /* Looks correct, but doesn't stick! */
}
Why this happens
position: sticky only works within the bounds of its nearest scrolling ancestor, and it stops working entirely if any ancestor between the sticky element and that scroll container has overflow set to anything other than visible (including hidden, scroll, or auto) — the sticky element becomes constrained to that ancestor's box instead of the true scroll container.

CSS
/* Fix: find the ancestor with overflow set and remove it, or move
   the sticky element outside of that ancestor's DOM subtree */
9. Transitions that don't fire on newly added elements

Symptom: adding a new element to the DOM with both its "before" and "after" transition state set in the same tick never actually animates — it just jumps straight to the final state.

HTML
<!-- element added dynamically, className already includes .is-visible -->
<div class="toast is-visible">Saved!</div>
Why this happens
A transition only animates a *change* in a property's value. If an element is inserted into the DOM already in its final state, the browser never observed the "before" value to transition from — there is nothing to interpolate between.

HTML
<!-- Fix: insert without the class first, then add it on the next
     frame so the browser registers a real style change to animate -->
<!-- element.classList.remove('is-visible')
     element.offsetHeight // force a reflow
     element.classList.add('is-visible') -->
10. Clicks passing through an overlapping element

Symptom: a transparent overlay (a loading spinner backdrop, a decorative icon layered over a button) silently blocks clicks on the element beneath it, even though it is invisible.

CSS
.icon-overlay {
  position: absolute;
  inset: 0;
  /* Invisible, but still captures pointer events by default! */
}

CSS
.icon-overlay {
  position: absolute;
  inset: 0;
  pointer-events: none; /* clicks pass through to whatever is underneath */
}
Diagnosing click-through bugs
In DevTools, right-click the unresponsive area and choose "Inspect" — whichever element the inspector actually selects (not the one you expected) is the one intercepting the click. It is almost always a positioned, transparent, or higher-z-index sibling sitting on top.
Quick reference

Bug

Root cause

Fix

Margins between siblings smaller than expected

Adjacent margin collapse

Use gap on a flex/grid parent instead

Flex child overflows with long text

Implicit min-width: auto on flex items

min-width: 0 on the shrinking child

z-index ignored

Trapped inside an ancestor stacking context

Find the ancestor with transform/opacity/filter, remove or restructure

100vh overflows on mobile

vh measures the largest possible viewport

Use 100dvh with a 100vh fallback

Small gap under an image

img is inline, reserves descender space

display: block on the image

Fixed element scrolls with the page

Ancestor transform creates a new containing block

Remove the transform, or render outside it

Element wider than its declared width

box-sizing: content-box default

Global box-sizing: border-box reset

position: sticky does not stick

An ancestor has overflow set to non-visible

Remove the ancestor overflow, or restructure the DOM

New element does not animate in

Inserted already in its final state, nothing to transition from

Add the "final state" class one frame after insertion

Click passes through an invisible layer

An overlapping positioned element captures pointer events

pointer-events: none on the non-interactive overlay

Next
Learn a systematic process for tracking down bugs like these in Debugging CSS.