Analysis

Choosing between contain and content-visibility

They are not alternatives; they answer different questions.

QuestionToolMeasured effect
"Most of this is offscreen — can the browser skip it?"content-visibility: auto98% 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: paintenables clipping optimisations, creates a containing block
"This box's size is independent of its content"contain: sizethe 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.
  • !important inverts. Measured. A design system using !important for 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 :root invalidates 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

ApproachRuntime costScopingDynamic themingWhere it hurts
Global CSS + BEMnoneconvention onlycustom propertiesdiscipline decays across teams
CSS Modulesnonebuild-time hashingcustom propertiescomposition across packages is awkward
Runtime CSS-in-JSstyle injection + serialisation per renderautomatictrivialSSR complexity, hydration cost, RSC friction
Zero-runtime CSS-in-JSnone (extracted)automaticlimited to what is statically knownbuild complexity; dynamic values fall back to inline styles or custom properties
Atomic / utility-firstnonen/a (no cascade to manage)custom propertiesmarkup verbosity; ejecting is a rewrite

Two observations that matter more than the table:

  1. 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.
  2. 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-change overuse. 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: paint all create one. A z-index that "stops working" is nearly always an ancestor that quietly became a stacking context — and contain: 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 !important count over time; it only ever goes up.
  • Custom property invalidation on the root. Theme switching by rewriting :root variables 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-left is a rewrite when RTL arrives; margin-inline-start is 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: size with 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.