gap·map

Modern CSS

[ middle depth ]

How custom properties differ from Sass variables

A Sass variable is gone after compilation. A custom property lives in the cascade: it inherits like color does, and var() is resolved per element, at computed-value time. That buys you what Sass can't: change --brand via JS, a media query, or a class, and every consumer restyles without a rebuild.

"Computed per element" has a flip side, the snapshot trap:

:root { --base: blue; --derived: var(--base); }
.card { --base: red; }
.card p { color: var(--derived); }  /* blue, not red */

--derived was resolved where it was declared (:root), and what descendants inherit is that computed result; the formula is gone. If you want the derivation to react, declare it low enough, or re-declare it where --base changes. Also: var() works only in property values. Selectors and media query conditions can't use it.

Invalid at computed-value time: why the fallback doesn't fire

The parser can't validate color: var(--accent) because substitution happens later. So the declaration is syntactically valid, wins the cascade normally, and only then gets its value:

:root { --accent: 24px; }
.btn { color: blue; }
.btn { color: var(--accent, red); }  /* neither blue nor red */

color: 24px is garbage, but it's too late to fall back to blue: that declaration already lost. The property behaves as unset, meaning the inherited value for inherited properties (color gets whatever the parent has) and initial otherwise. The red fallback doesn't fire either. A var() fallback fires only when the custom property is unset (guaranteed-invalid), never for a defined-but-wrong value. One more comma rule: everything after the first comma is the fallback, so var(--font, Georgia, serif) has the single fallback Georgia, serif. The typed defense against all of this is @property, covered in the senior notes.

How to structure tokens for theming and dark mode

The architecture that scales has two layers: primitive tokens (raw palette and scales like --blue-600, --space-4; no component ever reads these) and semantic tokens (--surface, --text-primary, --brand-accent) that components consume exclusively. A theme or brand is then a re-mapping at the root:

:root { color-scheme: light dark; --surface: #fff; --text: var(--gray-900); }
[data-theme="dark"] { --surface: var(--gray-900); --text: var(--gray-50); }

Switching means setting one attribute, so there is no stylesheet swap or FOUC, and the browser does a single recalc. Default to the OS with prefers-color-scheme and let the explicit attribute win as user override. color-scheme matters beyond politeness because it flips UA-rendered surfaces (form controls, scrollbars). light-dark(#fff, #111) collapses per-color pairs into one declaration, though it needs color-scheme set and it handles colors only (shadows and spacing are out of scope).

:has() as a parent and state selector

:has() anchors a relational check on an element: "match me if something matches inside/next to me." Former JS jobs become selectors:

.field:has(input:invalid:not(:focus)) .error { display: block; }
ul:has(> li:nth-child(5)) { columns: 2; }   /* quantity query: 5+ items */
h1:has(+ p) { margin-bottom: 0.25em; }      /* styling based on what FOLLOWS */

Chain for AND (:has(video):has(audio)), pass a list for OR (:has(video, audio)). The :not(:focus) in the validation pattern is the difference between "error after leaving the field" and "screaming while typing". Specificity works like :is(): the most specific argument counts. Two limits: no :has() inside :has(), and no pseudo-elements inside.

Fluid type with clamp() instead of breakpoint stacks

clamp(min, preferred, max) is max(MIN, min(VAL, MAX)): the preferred value rules the middle, the bounds cap it. One declaration replaces a stack of font-size media queries:

h1 { font-size: clamp(1.5rem, 1.2rem + 1.5vw, 2.5rem); }

The 1.2rem + term matters. clamp(16px, 4vw, 40px) is the classic mistake: a pure-viewport preferred value ignores the user's font-size setting and barely responds to zoom, and text must still be able to reach 200%. Mixing a rem term in keeps the scale anchored to user preferences. The same tool works for spacing scales; min()/max() cover one-sided bounds (width: min(90%, 60rem)).

What native CSS nesting desugars to

Nested rules wrap the parent selector in :is(), which is where the surprises live, since :is() takes its most specific argument (see css-specificity):

.a, #b {
  .c { }      /* :is(.a, #b) .c    specificity 1-0-1 even when matched via .a */
  &.on { }    /* :is(.a, #b).on    compound: & is REQUIRED */
  .dark & { } /* .dark :is(.a, #b)    appended & reverses */
}

Without &, a nested selector is a descendant: .warning inside .notice means .notice .warning, and the compound .notice.warning needs &.warning. The Sass habit that does not port is string concatenation. &__title does not build .block__title; native & is a selector reference, not a string. Nested @media inside a rule works and keeps declarations in source order.

[ senior depth ]

Typed custom properties with @property

An unregistered custom property is an untyped token stream: the browser can't validate it, can't interpolate it, and must inherit it. @property fixes all three:

@property --progress {
  syntax: "<percentage>";   /* required */
  inherits: false;          /* required */
  initial-value: 0%;        /* required unless syntax is "*" */
}
.ring { background: conic-gradient(teal var(--progress), #eee 0);
        transition: --progress 300ms; }

Three consequences follow.

  • Typed animation. The browser can now interpolate --progress, so gradient stops, angles, and numeric counters (things transition can't touch directly) animate through the custom property.
  • IACVT defense. An invalid assignment (--progress: red) snaps to initial-value instead of poisoning consumers with inherited/initial weirdness. A var() fallback never fires for a defined-but-wrong value; registration is the safety net that does.
  • inherits: false. Correct semantics for non-inherited tokens, and it skips inheritance propagation on huge trees.

Constraint: initial-value must be computationally independent. 0% qualifies; 3em does not (it depends on font-size).

CSS scoping strategies compared

The underlying problem: CSS has one global namespace and unscoped selectors. Every strategy is a different answer to "who may match my nodes", and each rung trades human discipline for tooling or platform guarantees.

  • BEM. Pure convention. Flat specificity, greppable, zero tooling. Cost: naming labor and discipline; nothing enforces it, and it decays under team churn.
  • CSS Modules. Build-time class hashing: real collision-freedom plus co-location. Cost: only class selectors are scoped (element selectors and inheritance still leak), composes is a weak composition story, and dynamic values still need custom properties anyway.
  • Tailwind. Utility-first. Wins: no naming, CSS output scales with the token count rather than page count, and the config enforces the design scale. Costs: markup verbosity, you still need a component layer for DRY (the abstraction moved into components), and bespoke or stateful CSS needs escape hatches. It also does not repeal the cascade: variants, third-party CSS, and inline styles still fight by the normal rules.
  • Runtime CSS-in-JS. Retreating for cause: per-render style serialization, style-tag churn, SSR extraction complexity, and incompatibility with server components. The survivors are zero-runtime (vanilla-extract, Linaria, StyleX), which are CSS Modules with a typed API.
  • Platform primitives. @layer ordering plus :where() zero-specificity defaults (mechanics in css-specificity; the point here is that "library styles lose to app styles" becomes a property of the layer order itself, with nothing left for a team to police). @scope (.card) to (.card-slot) gives donut scoping: style a subtree while excluding an inner hole, with proximity as a tiebreaker that sits below specificity. Shadow DOM is the only hard boundary (style and DOM), which makes it right for embedded third-party widgets and awkward for app UI (theming and composition friction).

:has() performance cost

:has() makes style invalidation relational: when a subtree mutates, the browser must re-check potential anchors. The cost model is anchor breadth × subtree churn. body:has(.modal[open]) on a huge, frequently-mutating DOM is how you buy jank, while .field:has(> input:invalid) is effectively free. Blanket claims in either direction ("it's slow", "it's fine") miss that the cost is a product of both factors. Constrain both ends, a specific anchor and >/+ in the argument, and it's fine in typical UIs.

Choosing an isolation strategy

Interviewers grade "pick an isolation strategy" as a constraints question, not a taste question: the segmentation comes before any technology name. Solo team or small app: Tailwind or Modules, whichever the team already reads fluently. Large org, many teams, shared design system: tokens as custom properties (primitive to semantic), @layer order as the contract, Modules or BEM at the component edges. Shipping a library: :where()-wrapped defaults in an early layer so consumers override without wars. Shipping an embed into pages you don't control: Shadow DOM, since you can't rely on the host page's cascade at all.

beta

The interviewer part is in the works.

The diagnostic, personal maps, and AI mock interviews are being finished right now. The notes stay free either way. Leave an email and you'll get the first-cohort invite, plus a month of Pro when it opens.

builds on

more CSS