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.
.box-a {
margin-bottom: 20px;
}
.box-b {
margin-top: 20px;
}
/* Gap between them is 20px (the larger margin wins), not 40px *//* 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.
.row {
display: flex;
gap: 12px;
}
.row .text-col {
flex: 1;
/* Still overflows with a long unbreakable string! */
}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..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.
.tooltip {
position: absolute;
z-index: 9999; /* still hidden behind .modal! */
}
.modal {
position: fixed;
z-index: 10;
}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./* 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 */
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.
.hero {
height: 100vh; /* includes space behind the collapsing address bar */
}vh is defined against the *largest possible* viewport, not the currently visible one — so 100vh is often taller than what is actually on screen..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.
.frame img {
/* No margin, no padding -- yet there's a ~4px gap below the image */
}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..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.
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./* 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.
.box {
width: 200px;
padding: 20px;
border: 2px solid black;
/* content-box (the default): rendered width = 200 + 40 + 4 = 244px */
}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.*, *::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.
.sticky-sidebar {
position: sticky;
top: 20px;
/* Looks correct, but doesn't stick! */
}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./* 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.
<!-- element added dynamically, className already includes .is-visible --> <div class="toast is-visible">Saved!</div>
<!-- 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.
.icon-overlay {
position: absolute;
inset: 0;
/* Invisible, but still captures pointer events by default! */
}.icon-overlay {
position: absolute;
inset: 0;
pointer-events: none; /* clicks pass through to whatever is underneath */
}Quick reference
Bug | Root cause | Fix |
|---|---|---|
Margins between siblings smaller than expected | Adjacent margin collapse | Use |
Flex child overflows with long text | Implicit |
|
z-index ignored | Trapped inside an ancestor stacking context | Find the ancestor with |
100vh overflows on mobile |
| Use |
Small gap under an image |
|
|
Fixed element scrolls with the page | Ancestor | Remove the transform, or render outside it |
Element wider than its declared width |
| Global |
position: sticky does not stick | An ancestor has | 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 |
|