CSSTidy CSS Formatter

CSS Patterns and Gotchas: Collapse, Containment, and Cascade Traps

CSSTidy — CSS Formatter & Minifier Guides · Updated 2026-10-17 · All guides

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 .card inside a .stack behaves and a .card in plain block flow does not);
  • a parent that has border, padding, inline content, or min-height/overflow other than visible separating 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: sticky inside it now sticks to that box — usually not what you wanted;
  • makes overflow-anchor and nested scrolling behave oddly on touch devices;
  • kills box-shadow and 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:

ValuePromisesBreaks
contain: layoutsubtree layout can't affect outsidecreates a stacking context and a containing block for fixed/absolute descendants — a dropdown inside it can now be clipped by its ancestors
contain: paintsubtree never paints outsideclips, plus everything layout breaks
contain: stylecounters/quotes can't leakonly counters; not a layout or paint boundary
contain: sizesubtree size ignores contentsyou must give the subtree a size or it collapses to zero
content-visibility: autoskip render work off-screenfind-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 position other than static and a z-index that is not auto;
  • position: fixed or sticky regardless of z-index;
  • opacity below 1, transform, filter, backdrop-filter, will-change, contain: paint;
  • animation running with a non-none fill.

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 @layer statement, not the order the layer's rules appear. @layer base, components; then writing @layer components rules before @layer base rules in the file still puts base first. If your framework CSS and your CSS arrive in the wrong order, the fix is one @layer statement at the top of your entry file, not !important.
  • Transitions and animations sit outside the cascade you're editing. A running animation overrides normal declarations (including ones with higher specificity) for the properties it animates; !important wins over animations, and transitions 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

  • color and font-* inherit; background, border, box-shadow, padding do not — which is why a :hover on a parent does not restyle an <input> inside it (input has its own UA styles).
  • line-height inherits as a number, not a computed px, which is why it scales in children; font-size inherits as a computed value.
  • currentColor resolves to the computed color on the element the property is used on, so an SVG stroke="currentColor" picks up the SVG's own color, not the link's.
  • all: unset does not reset unicode-bidi or direction, and all: initial will reset color to the UA default — that is why revert-layer/revert exist.

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 needs min-height: 0 on its parent chain to shrink — default min-height: auto on flex items is why a long code block refuses to scroll and instead pushes the page wider.
  • aspect-ratio yields to height unless you write height: auto.
  • width: 100% inside a padded container overflows by the padding; use max-width: 100% or box-sizing: border-box.
  • Flex justify-content: center on overflowing content clips the start of the content and you cannot scroll back to it. margin: auto on 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: auto bug: grid items also default to min-width/min-height: auto. A long unbreakable string in a 1fr column widens the whole grid; min-width: 0 on the item, or grid-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, and auto is not definite. height: 100% on a child of a height: auto parent resolves to auto — silently, no error. The modern fixes are min-height: 100dvh on the ancestor chain, or flex/grid alignment (align-items: stretch) instead of percentage heights.
  • display on <svg> is not what you think. An inline <svg> is a replaced element; display: block removes the baseline gap under it, but width/height on the SVG element fight its viewBox aspect ratio. Set one dimension and let the viewBox compute the other, and put overflow: visible on 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.

Developer Sponsor / Partner
Copied to clipboard!