Analysis
Choosing between contain and content-visibility
They are not alternatives; they answer different questions.
| Question | Tool | Measured effect |
|---|---|---|
| "Most of this is offscreen — can the browser skip it?" | content-visibility: auto | 98% off initial layout |
| "A change inside this box must not disturb the page" | contain: layout (+ size) | 80% off the update path |
| "This box paints nothing outside itself" | contain: paint | enables clipping optimisations, creates a containing block |
| "This box's size is independent of its content" | contain: size | the piece that made layout go from 18% → 80% |
Always pair content-visibility: auto with contain-intrinsic-size. Without it, skipped
subtrees measure as zero height, the scrollbar is wrong, and scroll position jumps as content is
revealed. contain-intrinsic-size: auto 240px tells the browser to assume 240px until it knows
better, and to remember the real value once measured.
Where content-visibility: auto does not apply: anything that must be findable by in-page search
or reachable by sequential focus while offscreen behaves differently (skipped content is still
searchable and focusable in modern Chromium, which is why it beats display:none, but be explicit
about testing it). Anything animating offscreen. Anything whose size genuinely cannot be estimated.
Cascade layers as organisational architecture
A layer stack is a statement about which team wins by default:
@layer reset, tokens, base, components, patterns, utilities, overrides;
Properties worth understanding before adopting:
- Order is fixed at first declaration. The
@layer a, b, c;statement should live in one place, loaded first. Layers declared later slot into the existing order; unknown layers append. - Unlayered wins. Any stylesheet you do not control — a third-party widget, an unmigrated legacy file — outranks your entire layer stack. During migration this is a feature: legacy CSS keeps working while new CSS is layered underneath.
!importantinverts. Measured. A design system using!importantfor defaults becomes unoverridable. This deserves a lint rule, not a convention.- Specificity still matters inside a layer. Layers do not remove the need for discipline; they remove the need for escalation.
The pre-layers technique was :where() for zero specificity, which is still useful for
selector-level control. Layers work at file/team scale; :where() works at rule scale.
Custom properties: power and cost are the same property
:root { --space: 8px; }
.card { padding: var(--space); }
[data-theme="compact"] { --space: 4px; } /* rebrands an entire subtree */
Because custom properties are inherited and resolved at computed-value time:
- changing one on
:rootinvalidates every element that inherits it — powerful for theming, and a real cost on large trees; - they cannot be interpolated by transitions/animations unless registered with
@property, which gives them a syntax and type; - they are readable and writable from JS (
getComputedStyle(el).getPropertyValue('--x')), which is what makes them the interop layer between design tokens and runtime theming; - they are not available at build time, so they cannot be used in media-query conditions.
The design-system consequence (fe-38): tokens should be custom properties, not preprocessor variables, precisely because runtime overridability is the requirement. The cost is that a token rename is a runtime break rather than a compile error — which is an argument for generating the token layer from a single source and type-checking consumers.
The CSS strategy comparison, honestly
| Approach | Runtime cost | Scoping | Dynamic theming | Where it hurts |
|---|---|---|---|---|
| Global CSS + BEM | none | convention only | custom properties | discipline decays across teams |
| CSS Modules | none | build-time hashing | custom properties | composition across packages is awkward |
| Runtime CSS-in-JS | style injection + serialisation per render | automatic | trivial | SSR complexity, hydration cost, RSC friction |
| Zero-runtime CSS-in-JS | none (extracted) | automatic | limited to what is statically known | build complexity; dynamic values fall back to inline styles or custom properties |
| Atomic / utility-first | none | n/a (no cascade to manage) | custom properties | markup verbosity; ejecting is a rewrite |
Two observations that matter more than the table:
- The runtime/build-time axis dominates the syntax axis. The meaningful question is whether styles are computed per render. Everything else is ergonomics — real, but not a performance decision.
- Cascade layers change the calculus. Much of the appeal of CSS Modules and utility-first CSS was escaping specificity conflicts. Layers address that directly, which makes plain CSS more viable at scale than it was when those approaches were adopted. Worth re-examining a decision made before layers shipped (fe-49).
What breaks at scale
will-changeoveruse. It promotes elements to their own compositor layer, consuming GPU memory. Applied broadly (.card { will-change: transform }over thousands of cards) it degrades the thing it was meant to improve. Apply immediately before an animation and remove after.- Accidental stacking contexts.
transform,opacity < 1,filter,will-change,contain: paintall create one. A z-index that "stops working" is nearly always an ancestor that quietly became a stacking context — andcontain: paint, added for performance, is a common culprit. - Specificity inflation is a ratchet. Without layers, every override raises the floor
permanently. The measurable symptom is
!importantcount over time; it only ever goes up. - Custom property invalidation on the root. Theme switching by rewriting
:rootvariables invalidates the whole document. Usually acceptable once per user action; not acceptable per frame, where it becomes a hidden animation cost. - Physical properties block internationalisation.
margin-leftis a rewrite when RTL arrives;margin-inline-startis not. This is the cheapest possible time to adopt logical properties and the most expensive to retrofit (fe-26). - Containment promises get broken by content.
contain: sizewith content that must grow produces clipped or zero-height boxes. The optimisation and the correctness constraint are the same declaration, so containment belongs in code review, not in a stylesheet-wide sweep.