CSS Patterns and Gotchas: Collapse, Containment, and Cascade Traps
Margin collapse is not a bug, and gap is the fix
Vertical margins between siblings merge into the larger of the two; a parent's top margin and its first child's top margin also merge, so the child appears to be pushed outside the parent. This is what makes a component's spacing change depending on where you place it.
.stack { display: flex; flex-direction: column; gap: 1rem; } /* never collapses */
.card { margin: 0; }
gap is not affected by collapse, which is the entire argument for gap + zero margins. A display: flow-root parent, or overflow: hidden, stops collapse through containment — but see the next section before choosing overflow: hidden as a fix.
The collapse rules are narrower than the folklore. They apply only to adjacent siblings' vertical margins, a parent's margin and its first/last child's margin, and self-collapsing empty blocks — and never to:
- floats, absolutely positioned boxes, and inline-level boxes (they never collapse);
- flex or grid containers (their children's margins never collapse, inside the container — the container's own margins still collapse with its parent's, which is why a
.cardinside a.stackbehaves and a.cardin plain block flow does not); - a parent that has border, padding, inline content, or
min-height/overflowother thanvisibleseparating it from the child — the "parent swallows child margin" bug needs a gap-free, border-free parent to exist at all.
The debug move: select the element in DevTools and read the computed margin box. If the highlighted margin area is smaller than your declared margin, collapse happened; the Computed pane shows the merged value, not your authored one.
overflow: hidden is not just a clip
Adding it fixes an overflow float and stops margin collapse. It also:
- creates a scroll container, so
position: stickyinside it now sticks to that box — usually not what you wanted; - makes
overflow-anchorand nested scrolling behave oddly on touch devices; - kills
box-shadowand outlines that intentionally bleed outside the box.
The container-friendly options are display: flow-root for collapse, and contain: layout or contain: paint when you mean "clip and stop affecting the outside".
One more consequence worth knowing before you paste overflow: hidden into a fix: overflow: hidden on an element makes it a scroll container, so any descendant's position: fixed still escapes it, but position: sticky does not — and screen-reader find-in-page behaviour on the clipped content differs across engines. If you only wanted a clip, clip-path: inset(0) clips without creating a scroll container, and contain: paint clips while keeping layout semantics.
contain does what overflow: hidden implies, without the side effects
contain: layout isolates layout, contain: paint clips, contain: style scopes counters. content-visibility: auto skips rendering work for off-screen subtrees and is the single biggest free perf win on a long list — it also breaks Ctrl+F find-in-page in some engines, so use it on lists you paginate, not on search results.
The containment values compose, and two of them have traps:
| Value | Promises | Breaks |
|---|---|---|
contain: layout | subtree layout can't affect outside | creates a stacking context and a containing block for fixed/absolute descendants — a dropdown inside it can now be clipped by its ancestors |
contain: paint | subtree never paints outside | clips, plus everything layout breaks |
contain: style | counters/quotes can't leak | only counters; not a layout or paint boundary |
contain: size | subtree size ignores contents | you must give the subtree a size or it collapses to zero |
content-visibility: auto | skip render work off-screen | find-in-page, print, and anchor scrolling behave differently per engine |
content-visibility: auto wants contain-intrinsic-size alongside it — without it, the scrollbar length changes as you scroll, because the engine guesses each skipped subtree's height and re-guesses when it renders. Pin the estimate and the jump disappears.
Stacking contexts: the reason z-index: 999999 fails
A z-index only competes inside its own stacking context. Each of these creates one, so a dropdown can be buried by a "lower" sibling that happens to be in a higher context:
- any element with
positionother thanstaticand az-indexthat is notauto; position: fixedorstickyregardless ofz-index;opacitybelow 1,transform,filter,backdrop-filter,will-change,contain: paint;animationrunning with a non-nonefill.
A transform or filter anywhere on an ancestor also re-anchors position: fixed, which is the standard explanation for "my modal scrolls with the page". Use two or three deliberate layers — 0, 100 for sticky header, 1000 for modals — and stop writing z-index: 9999.
The full list is longer than the folklore four: opacity < 1, transform, filter, backdrop-filter, perspective, clip-path, mask, will-change naming any of those properties, contain: paint, mix-blend-mode other than normal, isolation, and -webkit-overflow-scrolling: touch on iOS. The practical version of the debug: walk up the DOM from the buried element; the first ancestor matching any of those is the context that's trapping it. isolation: isolate is the deliberate way to create a context without any side effect — it's the right tool when you want a component's internal z-indices to stop competing with the page's.
The corollary nobody states: negative z-index is scoped too. A child with z-index: -1 paints behind its stacking-context parent's background only if the parent is not itself a context — put it in a transformed wrapper and it pops back above. This is the "why did my overlay background disappear" bug after someone adds a transform for an animation.
The cascade is not just specificity
Order of decision: origin and importance, then @layer, then specificity, then source order. A layered rule with an ID selector can lose to an unlayered class rule, because unlayered styles beat layered ones in the same origin. If you are adding !important to beat a library's #id rule, the correct fix is @layer — that is what it exists for.
:where() is how you write a reset that cannot fight your components: :where(h1, h2, h3) { margin: 0; font-size: inherit; } contributes zero specificity, so every real rule overrides it.
Two cascade details that produce "impossible" bugs:
- Layer order is declaration order of the first
@layerstatement, not the order the layer's rules appear.@layer base, components;then writing@layer componentsrules before@layer baserules in the file still puts base first. If your framework CSS and your CSS arrive in the wrong order, the fix is one@layerstatement at the top of your entry file, not!important. - Transitions and animations sit outside the cascade you're editing. A running
animationoverrides normal declarations (including ones with higher specificity) for the properties it animates;!importantwins over animations, andtransitions can make a corrected value look like it didn't apply because you're seeing the tween. "My fix works but only after the hover animation ends" is this rule, not a caching bug.
Inheritance surprises
colorandfont-*inherit;background,border,box-shadow,paddingdo not — which is why a:hoveron a parent does not restyle an<input>inside it (inputhas its own UA styles).line-heightinherits as a number, not a computed px, which is why it scales in children;font-sizeinherits as a computed value.currentColorresolves to the computedcoloron the element the property is used on, so an SVGstroke="currentColor"picks up the SVG's own color, not the link's.all: unsetdoes not resetunicode-bidiordirection, andall: initialwill resetcolorto the UA default — that is whyrevert-layer/revertexist.
The full inheritance list is short enough to memorise: color, font-*, line-height, letter-spacing, word-spacing, text-align, text-indent, text-transform, white-space, visibility, cursor, direction, list-style-*, quotes. Everything else — including background, border, padding, width, display, position, overflow — does not inherit. The two that fool people: visibility does inherit (so hiding a parent and overriding a child works, and visibility: collapse on table parts is its own engine-dependent mess), and text-shadow/box-shadow/filter do not (so a shadowed container does not shadow its children).
Custom properties inherit like color — which is both the feature and the leak: a --surface set on body is visible to every descendant, and a component that reads var(--surface) without a fallback silently depends on something an ancestor might redefine. Always write var(--surface, #fff); the fallback is the documentation of what the component assumed.
Form controls resist you on purpose
input and button do not inherit font-family. Fix globally: button, input, select, textarea { font: inherit; }. -webkit-appearance: none is required before you can style checkboxes and radios in WebKit; accent-color is the one-line alternative that keeps native behaviour.
Layout gotchas that look like bugs
min-height: 100%on a flex child needsmin-height: 0on its parent chain to shrink — defaultmin-height: autoon flex items is why a long code block refuses to scroll and instead pushes the page wider.aspect-ratioyields toheightunless you writeheight: auto.width: 100%inside a padded container overflows by the padding; usemax-width: 100%orbox-sizing: border-box.- Flex
justify-content: centeron overflowing content clips the start of the content and you cannot scroll back to it.margin: autoon the child does not have that bug.
Three more that generate most of the "CSS is broken" reports:
- Grid items refuse to shrink below their content. Same root as the flex
min-height: autobug: grid items also default tomin-width/min-height: auto. A long unbreakable string in a1frcolumn widens the whole grid;min-width: 0on the item, orgrid-template-columns: minmax(0, 1fr), restores shrinking. This is the #1 cause of "my grid overflows on mobile but the widths are all 1fr". %heights need a definite parent height, andautois not definite.height: 100%on a child of aheight: autoparent resolves toauto— silently, no error. The modern fixes aremin-height: 100dvhon the ancestor chain, or flex/grid alignment (align-items: stretch) instead of percentage heights.displayon<svg>is not what you think. An inline<svg>is a replaced element;display: blockremoves the baseline gap under it, butwidth/heighton the SVG element fight itsviewBoxaspect ratio. Set one dimension and let the viewBox compute the other, and putoverflow: visibleon the SVG if strokes extend past the viewBox edge (they're clipped by default, unlike HTML).
Formatting and tooling traps
The gotchas above are layout and cascade; there is a second class where the build step changes behaviour. A minifier that strips /* … */ without knowing where strings begin corrupts content: "x/*y*/z"; a beautifier that breaks lines at every } can cut a quoted "}" in half. Both failures produce valid-looking CSS with wrong values — which is worse than an error, because it ships.
Formatting notes for anything above: minify safety covers the one place a formatter damages values rather than layout, and the cheat sheet holds the selector and @-rule tables.