HTML & CSS NotesRohit’s interview study guide
Chapter 11

CSS Architecture & Tooling

Why CSS gets harder as a codebase grows, and the conventions and tools teams use to keep it in check: BEM, CSS Modules, Sass and Less, Tailwind, CSS-in-JS, PostCSS and resets, each shown with the real CSS it produces.

9 topics
Parts marked Advanced are extra depth. Skip them on a first read or a quick revision.

#Why CSS gets hard to maintain

Added

CSS is easy to write and hard to keep tidy, and the reason is structural, not a matter of discipline. Every selector in every stylesheet lives in one global namespace and competes in one cascade. A rule written for the checkout page can style a heading on the blog, because nothing in the language ties .title to the file or the component it was written for. When two rules match the same element, the winner is decided by origin, layers, specificity and then source order (see The cascade), not by which team meant it.

A useful mental model is a shared office whiteboard with no names on anything. Anyone can write on it, nobody can tell who wrote what, and erasing something might break a meeting you have never heard of. So people only ever add: a more specific selector to beat the last one, an !important to beat that, and old rules nobody dares delete. Stylesheets grow append-only, and specificity climbs.

Every approach in this chapter attacks one or more of these forces: naming conventions (BEM) and generated class names (CSS Modules, CSS-in-JS) fight the global namespace; utility classes (Tailwind) avoid writing new selectors at all; cascade layers and @scope give the cascade structure; tooling (Sass, PostCSS) makes the source easier to organise and to ship. The demo shows the forces in miniature.

Two teams, one stylesheet
CSS
/* checkout.css — written first */
.title { color: rgb(29, 78, 216); font-size: 18px; margin: 0 0 8px; }

/* blog.css — another team, loaded later */
.title { color: rgb(185, 28, 28); }

/* "fix" for the sidebar */
#sidebar .title { color: rgb(21, 128, 61); }

/* "fix" for the fix */
.promo .title { color: rgb(126, 34, 206) !important; }
HTML
<section class="checkout">
  <h2 class="title">Order summary</h2>
</section>
<aside id="sidebar">
  <h2 class="title">Related posts</h2>
  <div class="promo">
    <h2 class="title">Sale</h2>
  </div>
</aside>
Resultlive, in your browser
What’s happening
  1. Both stylesheets define .title, with the same specificity (0,1,0). The checkout team's "Order summary" was meant to be blue, but blog.css comes later in the source, so for the color property the later rule wins: it renders rgb(185, 28, 28), the blog's red. Nobody on the checkout team changed anything.
  2. The leak goes the other way too: font-size: 18px and the margin were only in checkout.css, and the blog rule didn't set them, so all three headings, including the sidebar ones, measure 18px. Rules merge property by property, so a "harmless" declaration travels to every .title on the site.
  3. Someone needs the sidebar title green and writes #sidebar .title. That's specificity (1,1,0), which beats both class rules, so "Related posts" is rgb(21, 128, 61). It works, and it means any later rule for titles in the sidebar now needs an id too.
  4. The promo inside the sidebar needs purple. .promo .title is (0,2,0), which loses to the id rule, so the author reaches for !important and the "Sale" heading is rgb(126, 34, 206). The only way to override that later is another !important with higher specificity, or a later one.
  5. This is the whole maintenance problem in four rules: a global name collided, source order silently decided a winner, and a specificity war started. In a real app the order of checkout.css and blog.css is often decided by the bundler, which makes step 1 change between builds.
AdvancedSix forces and what fights each

The forces, and what fights each one:

ForceWhat goes wrongWhat fixes it
Global namespaceTwo .title rules from different files style each other's elementsNaming conventions (BEM), generated names (CSS Modules, CSS-in-JS), @scope
Specificity escalationIds, long chains and !important to beat earlier rulesFlat single-class selectors, :where() for zero specificity, cascade layers
Source-order dependenceThe winner changes when the bundler reorders CSS chunksLayers (order declared once), scoped names that never collide
Append-only growthNobody knows which rules are unused, so nothing is deletedStyles co-located with components (delete the component, delete its CSS), utilities generated from what's used (Tailwind)
Coupling to markup.sidebar > div > ul li a breaks when the HTML changesClasses on the element itself instead of structural chains
Inheritance surprisescolor, font and line-height flow into components from their parentsComponents set the inherited properties they rely on; resets set a known baseline (Resets)
AdvancedGotcha: CSS order in code-split apps
AdvancedMeasuring dead CSS and specificity
Swap the file order

Using the demo's four rules, suppose the bundler now loads blog.css before checkout.css. What colour is each heading?

Show answer

"Order summary" turns blue, the two sidebar headings don't change (green and purple).

What’s happening
  1. The two .title rules still have equal specificity (0,1,0), so source order decides. Now checkout.css comes last, so its color: rgb(29, 78, 216) wins for every .title that nothing stronger targets.
  2. "Related posts" is still matched by #sidebar .title (1,1,0), which beats any single class regardless of order: green.
  3. "Sale" still has an !important declaration. Important declarations beat all normal ones whatever their specificity or order: purple.
  4. So only the element whose winner was decided by order changed. That's why order-dependent CSS is fragile: the same files produce different pages when they are concatenated differently.

#Naming conventions: BEM

Added

BEM (Block, Element, Modifier) is a naming convention, not a tool: it solves the global namespace by making every class name unique by construction, and it keeps specificity flat by styling everything with one class. A block is a standalone component (card); an element is a part that only makes sense inside its block, joined with two underscores (card__title); a modifier is a variant or state, joined with two hyphens (card--featured, button--small).

The mental model is a postal address. card__title is "the title that belongs to the card", the same way "flat 4, 12 King Street" can't be confused with flat 4 anywhere else. Because the block name is part of every class, a .card__title can't collide with the blog's .title, and because each selector is a single class, every rule has specificity (0,1,0) and source order inside the block's own file is the only tie-breaker you need to think about.

A BEM card next to a legacy rule
CSS
/* legacy, somewhere else in the app */
.title { color: rgb(185, 28, 28); text-transform: uppercase; }

/* card.css */
.card { width: 220px; padding: 12px; border: 2px solid rgb(203, 213, 225); border-radius: 8px; margin-bottom: 8px; }
.card__title { margin: 0 0 4px; font-size: 16px; color: rgb(30, 41, 59); }
.card__price { margin: 0; color: rgb(71, 85, 105); }
.card--featured { border-color: rgb(217, 119, 6); }
.card--featured .card__title { color: rgb(146, 64, 14); }
HTML
<article class="card">
  <h3 class="card__title">Basic</h3>
  <p class="card__price">$4 / month</p>
</article>
<article class="card card--featured">
  <h3 class="card__title">Pro</h3>
  <p class="card__price">$12 / month</p>
</article>
Resultlive, in your browser
What’s happening
  1. The legacy .title rule is still in the page and is red and uppercase, but no element in the card has the class title; the heading's class is card__title, a different name. So the titles compute to rgb(30, 41, 59) and text-transform: none. The collision from the previous topic is impossible, not just unlikely.
  2. The second card has both classes, card card--featured. The block class gives it everything a card has (it still measures 220px wide with 12px padding), and the modifier only adds the difference: its border is rgb(217, 119, 6) while the plain card keeps rgb(203, 213, 225).
  3. .card--featured and .card both have specificity (0,1,0), so the modifier wins only because it comes after the block in the file. That's the one ordering rule BEM needs: block, then elements, then modifiers, in the block's own file.
  4. The last rule, .card--featured .card__title, is the only two-class selector. BEM allows a block modifier to restyle its own elements this way; the result is the featured title in rgb(146, 64, 14). Everything else is one class.
  5. The test lists every card selector in the stylesheet: .card, .card__title, .card__price, .card--featured, .card--featured .card__title. No ids, no tag chains, no DOM structure. That flatness is what makes BEM components safe to move around the page.
AdvancedBEM naming rules that trip people up

The naming rules that trip people up

  • Elements belong to the block, not to the DOM tree. A title inside a header inside a card is still card__title, never card__header__title. Grandchild names lock the class to today's markup; when you move the title out of the header, the name lies.
  • Modifiers never stand alone. class="card--featured" without card gets none of the base styles. Always write card card--featured. (Sass's @extend can paper over this, but it generates surprising selectors; the double class is clearer.)
  • Modifiers can be boolean or key–value: button--disabled, button--size-small. States that come from the platform are better expressed with the platform: :disabled, [aria-expanded="true"], :focus-visible rather than a button--focused class (see ARIA roles, states and properties).
  • A mix puts two blocks' classes on one element: <button class="card__action button button--small">. card__action handles placement inside the card (margins, alignment); button owns how a button looks. That keeps layout concerns out of the reusable block.
  • No styling by tag or id inside a block. .card h3 would also style any h3 a child component renders inside the card. Give the element its own class.
AdvancedWriting BEM with the Sass & selector

BEM with Sass

The & parent selector makes BEM pleasant to write, because the block name is typed once. This compiles (Dart Sass 1.105.2) to the flat selectors you'd write by hand:

card.scss
.card {
  padding: 12px;

  &__title {
    font-size: 16px;
  }

  &--featured {
    border-color: #d97706;
  }
}
card.css (compiled)
.card {
  padding: 12px;
}
.card__title {
  font-size: 16px;
}
.card--featured {
  border-color: #d97706;
}
What’s happening
  1. & stands for the parent selector text, so &__title inside .card is literally .card + __title = .card__title. Sass concatenates strings; it isn't nesting the selector.
  2. The output has no descendant combinators at all, so nesting in the source did not raise specificity. That's different from writing & .title (with a space), which would produce .card .title at (0,2,0).
  3. The downside: you can't search the codebase for card__title and find its rule, because that string never appears in the source. Some teams ban &__ for exactly that reason.
AdvancedOOCSS, SMACSS, ITCSS, SUIT and CUBE

Other methodologies you'll hear about

  • OOCSS (Object-Oriented CSS, Nicole Sullivan): separate structure from skin and container from content; reusable "objects" like .media.
  • SMACSS (Jonathan Snook): categorise rules as base, layout, module, state (.is-active) and theme.
  • ITCSS (Harry Roberts): order the whole codebase as an inverted triangle, from far-reaching, low-specificity rules (settings, tools, generic resets, elements) to narrow, high-specificity ones (objects, components, utilities). Cascade layers now let the browser enforce that order (see Choosing an approach).
  • SUIT CSS: BEM-like with component names in PascalCase: ComponentName-descendant--modifier, is-state.
  • CUBE CSS (Andy Bell): Composition, Utility, Block, Exception; leans on the cascade and global styles first, with blocks only for what's left, and data attributes for exceptions.

They differ in vocabulary, but all of them aim for the same two things BEM does: predictable names and low, flat specificity.

What's wrong with these class names?
HTML
<div class="card">
  <div class="card__header">
    <h3 class="card__header__title title--big">Pro</h3>
  </div>
</div>
Show answer

Two problems: card__header__title encodes the DOM path, and title--big is a modifier without its block.

What’s happening
  1. In BEM, elements are flat children of the block, whatever their depth in the markup. The title should be card__title. With card__header__title, moving the title out of the header (a common redesign) means renaming the class everywhere it's used.
  2. title--big modifies a block called title, but there is no title class on the element, so no base styles apply and the modifier has nothing to modify. It should be a modifier of the element: card__title card__title--big.
  3. Also check title--big against the legacy .title rule from the previous topic: a team that half-adopts BEM still has generic names in play, and the collisions come back.

#CSS Modules

Added

CSS Modules give you BEM's guarantee automatically: you write ordinary CSS with short local names (.title), and the build tool renames every class to a unique, generated name per file and gives your JavaScript an object that maps the names you wrote to the names it generated. Two files can both define .title, and they can never touch each other's elements, because in the browser neither class is called title.

The mental model is a JavaScript module's top-level variables: const title in one file and const title in another are different bindings. A *.module.css file is CSS with its own scope. It's a build-time convention (the original spec lives in the css-modules GitHub organisation, not at the W3C), supported with no configuration by Vite, Next.js, Parcel, Rspack and webpack's css-loader. Below is the real output of postcss-modules 9.0.1 (the plugin Vite uses) for two files that both use .title.

Card.module.css
.card {
  padding: 12px;
  border: 1px solid #c9d3e6;
}

.title {
  color: #1d4ed8;
}

.featured {
  composes: card;
  border-color: #d97706;
}

.card:hover .title {
  text-decoration: underline;
}

:global(.dark) .card {
  background: #1f2430;
}
Alert.module.css
.title {
  color: #b91c1c;
}
Generated CSS
._card_4ut92_1 {
  padding: 12px;
  border: 1px solid #c9d3e6;
}

._title_4ut92_6 {
  color: #1d4ed8;
}

._featured_4ut92_10 {
  border-color: #d97706;
}

._card_4ut92_1:hover ._title_4ut92_6 {
  text-decoration: underline;
}

.dark ._card_4ut92_1 {
  background: #1f2430;
}

._title_ppcpv_1 {
  color: #b91c1c;
}
What JavaScript imports
// import card from "./Card.module.css"
{
  "card": "_card_4ut92_1",
  "title": "_title_4ut92_6",
  "featured": "_featured_4ut92_10 _card_4ut92_1"
}
// import alert from "./Alert.module.css"
{ "title": "_title_ppcpv_1" }
What’s happening
  1. Every class is renamed to _[name]_[hash]_[line]: .card on line 1 becomes ._card_4ut92_1, .title on line 6 becomes ._title_4ut92_6. The hash (4ut92) is derived from the file, so Alert.module.css's .title becomes ._title_ppcpv_1. Same name you wrote, different class in the browser: no collision is possible.
  2. The JavaScript side is a plain object. card.title is the string "_title_4ut92_6", so a component writes class={card.title} (React: className={card.title}). Nothing runs at runtime except reading a string.
  3. composes: card does not copy declarations: the generated ._featured_… rule only has border-color. Instead, featured maps to two class names, "_featured_4ut92_10 _card_4ut92_1", so the element gets both rules. Composition happens in the class list, not in the CSS.
  4. Selectors, pseudo-classes and media queries survive untouched apart from the renaming: .card:hover .title became ._card_4ut92_1:hover ._title_4ut92_6. It's still real CSS, with real specificity (this one is (0,3,0)).
  5. :global(.dark) opts one class out of renaming, giving .dark ._card_4ut92_1. That's the escape hatch for classes set from outside the module, such as a theme class on <html>.
AdvancedGenerated module CSS rendered live

The generated CSS, rendered

Here is that generated CSS with the HTML a component would render from the import objects. Both files used the name .title, and both headings keep their own colour.

Two modules, both with .title
CSS
._card_4ut92_1 { padding: 12px; border: 1px solid #c9d3e6; margin-bottom: 8px; }
._title_4ut92_6 { color: #1d4ed8; margin: 0; font-size: 16px; }
._featured_4ut92_10 { border-color: #d97706; }
._card_4ut92_1:hover ._title_4ut92_6 { text-decoration: underline; }
.dark ._card_4ut92_1 { background: #1f2430; }
._title_ppcpv_1 { color: #b91c1c; margin: 0; font-size: 16px; }
HTML
<div class="_card_4ut92_1"><h3 class="_title_4ut92_6">Card title</h3></div>
<div class="_featured_4ut92_10 _card_4ut92_1"><h3 class="_title_4ut92_6">Featured card</h3></div>
<div role="alert"><h3 class="_title_ppcpv_1">Alert title</h3></div>
Resultlive, in your browser
What’s happening
  1. The card heading has _title_4ut92_6 and computes to rgb(29, 78, 216) (#1d4ed8); the alert heading has _title_ppcpv_1 and computes to rgb(185, 28, 28) (#b91c1c). Both source files said .title; the browser never saw that name.
  2. The featured card carries both classes from featured's mapping, so it gets 12px padding from the card rule and rgb(217, 119, 6) borders from its own rule, while the plain card keeps rgb(201, 211, 230) (#c9d3e6).
  3. The hover rule is in the stylesheet as ._card_4ut92_1:hover ._title_4ut92_6; hover over a card in the live result to see the underline (a test can't hover, so it only checks the rule exists).
  4. I added margin and font-size to the two title rules so the demo is readable; everything else is the generator's output verbatim.
AdvancedSame module through Lightning CSS

The same file through Lightning CSS

Lightning CSS (1.33.0) also implements CSS Modules, and Vite can use it instead of PostCSS (css.transformer: "lightningcss"). The naming pattern differs, which is why you should never hard-code a generated name:

Lightning CSS output (excerpt)
.WQI9iq_card {
  border: 1px solid #c9d3e6;
  padding: 12px;
}

.WQI9iq_title {
  color: #1d4ed8;
}

.WQI9iq_featured {
  border-color: #d97706;
}
What’s happening
  1. The pattern is [hash]_[local], so .card becomes .WQI9iq_card. The exports object it returns is richer: featured is reported as { name: "WQI9iq_featured", composes: [{ type: "local", name: "WQI9iq_card" }] }, which the bundler joins into the same two-class string.
  2. Lightning CSS also reorders padding and border in the output; it's a minifier and transformer as well as a modules implementation. The meaning is the same.
  3. Tests, snapshots or other CSS that hard-code _title_4ut92_6 break the moment the file, the tool or the config changes. Select by role, text or a data- attribute in tests instead.
AdvancedLimits: silent typos, only classes scoped

Limits of CSS Modules

#Preprocessors: Sass and Less

Added

A preprocessor is a compiler for a language that is a superset of CSS. You write .scss (Sass) or .less (Less) with variables, nesting, mixins, functions, loops and modules, and a build step turns it into plain CSS. The browser never sees Sass; it only sees the output. That's the key fact for interviews: Sass features are resolved at build time, so a Sass variable is a constant baked into the CSS, while a CSS custom property is a live value the browser can change at runtime.

The mental model is a template engine for stylesheets, like a server-side template that outputs HTML. Sass (the .scss syntax, implemented today by Dart Sass; LibSass and node-sass are deprecated) is by far the most used. Less is older, JavaScript-based, and still found in codebases built on Bootstrap 3 or Ant Design. Both compiled below with the versions noted.

Sass source, compiled CSS, live result
button.scss (source, Dart Sass 1.105.2)
@use "sass:color";
@use "sass:map";
@use "tokens" as t; // _tokens.scss: $space: 8px; $radius: 6px;
                    // $tones: ("info": #2563eb, "warn": #b45309, "danger": #b91c1c)

@mixin focus-ring($c) {
  outline: 2px solid $c;
  outline-offset: 2px;
}

@function space($n) {
  @return $n * t.$space;
}

.btn {
  padding: space(1) space(2);
  border-radius: t.$radius;
  background: map.get(t.$tones, "info");
  color: #fff;

  &:focus-visible {
    @include focus-ring(map.get(t.$tones, "info"));
  }

  &__icon {
    margin-inline-end: space(1);
  }

  @each $name, $tone in t.$tones {
    &--#{$name} {
      background: $tone;
      border: 1px solid color.adjust($tone, $lightness: -10%);
    }
  }
}
CSS
.btn {
  padding: 8px 16px;
  border-radius: 6px;
  background: #2563eb;
  color: #fff;
}
.btn:focus-visible {
  outline: 2px solid #2563eb;
  outline-offset: 2px;
}
.btn__icon {
  margin-inline-end: 8px;
}
.btn--info {
  background: #2563eb;
  border: 1px solid rgb(7.2829131653%, 29.8599439776%, 79.3837535014%);
}
.btn--warn {
  background: #b45309;
  border: 1px solid rgb(51.5406162465%, 23.7659508248%, 2.5770308123%);
}
.btn--danger {
  background: #b91c1c;
  border: 1px solid rgb(55.1781275891%, 8.3512841756%, 8.3512841756%);
}
HTML
<button class="btn btn--info"><span class="btn__icon">i</span>Info</button>
<button class="btn btn--warn">Warn</button>
<button class="btn btn--danger">Delete</button>
Resultlive, in your browser
What’s happening
  1. @use "tokens" as t loads _tokens.scss once as a module with the namespace t, so its variables are read as t.$space and t.$tones. The leading underscore marks a partial: a file that is only ever loaded by others and never compiled on its own.
  2. space(1) space(2) calls a Sass @function at compile time: 1 * 8px and 2 * 8px. The output just says padding: 8px 16px, and the button measures 8px top and 16px left padding. There is no trace of the function in the CSS.
  3. @include focus-ring(…) pastes the mixin's declarations into .btn:focus-visible. The test focuses the button and reads a 2px solid outline in rgb(37, 99, 235) with a 2px offset, all from the mixin.
  4. @each $name, $tone in t.$tones loops over the map at compile time and writes one modifier per entry: .btn--info, .btn--warn, .btn--danger, with backgrounds rgb(37, 99, 235), rgb(180, 83, 9) and rgb(185, 28, 28). Three entries in the map, three rules in the output.
  5. color.adjust($tone, $lightness: -10%) darkens each colour. Dart Sass 1.79+ keeps full precision, so the output is rgb(7.28…%, 29.86…%, 79.38…%) rather than a hex code. Chrome computes it to rgb(19, 76, 202), exactly the colour Less writes as #134cca (below).
  6. The compiled CSS uses plain single-class BEM selectors. Sass helped write it, but the browser gets ordinary CSS; nothing about Sass exists at runtime.
AdvancedThe same button written in Less

The same button in Less

Less (4.9.1) has the same ideas with different syntax: @ variables, mixins that are just classes called like functions, and each() over a detached ruleset used as a map.

button.less
@space: 8px;
@radius: 6px;
@tones: {
  info: #2563eb;
  warn: #b45309;
  danger: #b91c1c;
};

.focus-ring(@c) {
  outline: 2px solid @c;
  outline-offset: 2px;
}

.btn {
  padding: @space (@space * 2);
  border-radius: @radius;
  background: @tones[info];
  color: #fff;

  &:focus-visible {
    .focus-ring(@tones[info]);
  }

  &__icon {
    margin-inline-end: @space;
  }

  each(@tones, {
    &--@{key} {
      background: @value;
      border: 1px solid darken(@value, 10%);
    }
  });
}
lessc output
.btn {
  padding: 8px 16px;
  border-radius: 6px;
  background: #2563eb;
  color: #fff;
}
.btn:focus-visible {
  outline: 2px solid #2563eb;
  outline-offset: 2px;
}
.btn__icon {
  margin-inline-end: 8px;
}
.btn--info {
  background: #2563eb;
  border: 1px solid #134cca;
}
.btn--warn {
  background: #b45309;
  border: 1px solid #833d07;
}
.btn--danger {
  background: #b91c1c;
  border: 1px solid #8d1515;
}
What’s happening
  1. Variables start with @ in Less, which is why Less can't use CSS at-rules as freely and why it reads @tones[info] for a map lookup.
  2. .focus-ring(@c) with parentheses is a mixin that produces no CSS of its own; a mixin defined without parentheses would also be output as a class.
  3. each(@tones, …) exposes @key and @value for each entry; &--@{key} interpolates the key into the selector, giving .btn--info, .btn--warn and .btn--danger.
  4. Less's darken(#2563eb, 10%) rounds to #134cca = rgb(19, 76, 202), the same colour Sass wrote at full precision. Same idea, same output, different spelling.
AdvancedSass deprecations: @import and global functions

Modules, not @import: what the current Sass warns about

Older Sass code uses @import and global functions. Dart Sass 1.80.0 deprecated both; they still compile in 1.x but print warnings, and the Sass team plans to remove them in Dart Sass 3.0.0. This is the real output of 1.105.2:

old.scss
@import "tokens";

.old {
  background: darken(map-get($tones, "info"), 10%);
  padding: $space;
}
Terminal (warnings trimmed)
DEPRECATION WARNING [import]: Sass @import rules are deprecated
and will be removed in Dart Sass 3.0.0.

DEPRECATION WARNING [global-builtin]: Global built-in functions
are deprecated and will be removed in Dart Sass 3.0.0.
Use map.get instead.

DEPRECATION WARNING [global-builtin]: Global built-in functions
are deprecated and will be removed in Dart Sass 3.0.0.
Use color.adjust instead.

DEPRECATION WARNING [color-functions]: darken() is deprecated.
Suggestions:
color.scale($color, $lightness: -18.75%)
color.adjust($color, $lightness: -10%)
What’s happening
  1. @import dumps everything from tokens into one global namespace, so every file sees every variable and mixin, and the same file imported twice is compiled twice. @use loads a file once, namespaces it (t.$space) and keeps $_private members private.
  2. map-get and darken are global functions; their replacements live in built-in modules loaded with @use "sass:map" and @use "sass:color".
  3. darken() gets its own warning because "10% darker" is ambiguous; Sass suggests color.adjust (subtract 10 points of lightness, what darken did) or color.scale (scale lightness proportionally).
  4. The output CSS was still produced (the same colour as above), and the exit code was 0. In CI you can make a deprecation fatal with --fatal-deprecation=import. The official sass-migrator module tool rewrites @import to @use.
AdvancedSass variables vs custom properties

Compile time vs runtime: Sass variables vs custom properties

This is the interview question about preprocessors. A Sass variable doesn't exist after compilation, so it can't change with a class, a media query or JavaScript. Both outputs below are real Dart Sass output:

scope.scss
$brand: #2563eb;

.link {
  color: $brand;
}

.dark {
  $brand: #93c5fd;
  background: #111827;
}

.dark .link {
  color: $brand;
}
scope.css (compiled)
.link {
  color: #2563eb;
}

.dark {
  background: #111827;
}

.dark .link {
  color: #2563eb;
}
vars.scss
$brand: #2563eb;

:root {
  --brand: #{$brand};
}

.dark {
  --brand: #93c5fd;
}

.link {
  color: var(--brand);
}
vars.css (compiled)
:root {
  --brand: #2563eb;
}

.dark {
  --brand: #93c5fd;
}

.link {
  color: var(--brand);
}
What’s happening
  1. In scope.scss, $brand: #93c5fd inside .dark { } creates a local variable that shadows the global one only inside that block. The block ends, the local disappears, and .dark .link compiles with the global #2563eb. Sass scope is lexical (where it's written), not about which elements match.
  2. The light blue never reaches the output at all; the "dark theme" silently didn't happen.
  3. In vars.scss, Sass only writes the initial value into a custom property (#{$brand} interpolation). The theme switch is done by CSS: .dark redefines --brand, and var(--brand) is resolved by the browser per element, through inheritance.
  4. Rule of thumb: use Sass variables (or nothing) for build-time constants such as breakpoints in media queries, and custom properties for anything that varies by theme, component state or user setting (see Custom properties and Theming and dark mode).
AdvancedSass features native CSS now covers

What native CSS now covers

Most reasons teams adopted Sass are now in the platform, all Baseline in 2026 (status per MDN, October 2026; see What's new in CSS since 2023):

Sass featureNative CSS equivalentDifference
$variablesCustom properties, var()Native ones are live: they inherit and change at runtime
Nesting, &Native nestingNative & can't concatenate names: &__title is not BEM in CSS
darken(), mix()color-mix(), relative colourComputed by the browser, works with var()
Math, math.divcalc(), min(), max(), clamp()Can mix units (100% - 2rem) at runtime
Partials, @use@import, @layerNative @import is a network request unless a bundler inlines it
Mixins, @each, mapsNo equivalent in browsers todayStill the main reason to keep Sass
AdvancedPitfall: native nesting drops &__ rules

Native nesting is not Sass nesting

In Sass, &__title concatenates strings into .card__title. Native CSS nesting treats & as a selector (it behaves like :is(.card)), not as text, so the same source means something else in the browser:

Sass-style BEM nesting, given to the browser
CSS
.card {
  color: rgb(71, 85, 105);
  &__title { color: rgb(185, 28, 28); }
  &--featured { color: rgb(217, 119, 6); }
  & .card__title { font-weight: 700; }
}
HTML
<div class="card"><h3 class="card__title">Title</h3></div>
<div class="card card--featured">Featured</div>
Resultlive, in your browser
What’s happening
  1. Chrome parsed the .card rule and kept only one nested rule, & .card__title. The &__title and &--featured rules are gone from the stylesheet: they aren't valid selectors once & means "the parent selector" rather than "the parent's text".
  2. So the title isn't red; it inherits the card's rgb(71, 85, 105). The featured card isn't amber either. Nothing in DevTools' console reports this; invalid rules are dropped silently.
  3. & .card__title (with a space) is a real descendant selector and works: the title is 700 weight. When you move BEM-style Sass to native nesting, write &__ and &-- names out in full, or keep Sass for those files.
Does the dark theme work?

Using scope.scss above, what colour is a .link inside <div class="dark">, and why?

Show answer

#2563eb, the light-theme blue. The dark value never makes it into the CSS.

What’s happening
  1. Sass evaluates .dark .link { color: $brand; } at compile time, looking $brand up lexically. The only $brand in scope at that point is the global #2563eb; the $brand assigned inside .dark { } was local to that block and is gone.
  2. So the compiled rule is .dark .link { color: #2563eb; }, a constant. The browser has no idea a variable was ever involved.
  3. Fix it with a custom property (vars.scss), or by writing the value directly in the .dark .link rule. If you really wanted to change the global, Sass has !global, but that changes it for every rule after that point in the file, still not per element.

#Utility-first CSS: Tailwind

Added

Utility-first CSS flips the usual approach. Instead of inventing a class per component (.pricing-card__button) and writing its CSS, you compose small single-purpose classes in the markup: px-4 sets inline padding, bg-blue-600 sets a background, md:px-6 sets more padding from the md breakpoint up. Tailwind is the dominant utility framework, and its key trick is that it scans your source files for class names and generates CSS only for the utilities you actually use.

The mental model is a set of Lego bricks with a fixed palette. You never write a new selector, so there's nothing to collide, nothing to win a specificity war, and nothing append-only: when you delete markup, its utilities stop being generated on the next build. The cost is moved into the markup, which gets long, and into the design system, because the bricks come from a theme you configure. The demo is Tailwind 4.3.3's real output for one button, using only its theme and utilities layers (no reset) so the output is short.

Tailwind input, generated CSS, live result
btn.html
<button class="rounded-md bg-blue-600 px-4 py-2 font-semibold text-white hover:bg-blue-700 md:px-6">Choose plan</button>
app.css (input)
@layer theme, utilities;
@import "tailwindcss/theme.css" layer(theme);
@import "tailwindcss/utilities.css" layer(utilities) source(none);
@source "./btn.html";
CSS
/*! tailwindcss v4.3.3 | MIT License | https://tailwindcss.com */
@layer properties;
@layer theme, utilities;
@layer theme {
  :root, :host {
    --color-blue-600: oklch(54.6% 0.245 262.881);
    --color-blue-700: oklch(48.8% 0.243 264.376);
    --color-white: #fff;
    --spacing: 0.25rem;
    --font-weight-semibold: 600;
    --radius-md: 0.375rem;
  }
}
@layer utilities {
  .rounded-md {
    border-radius: var(--radius-md);
  }
  .bg-blue-600 {
    background-color: var(--color-blue-600);
  }
  .px-4 {
    padding-inline: calc(var(--spacing) * 4);
  }
  .py-2 {
    padding-block: calc(var(--spacing) * 2);
  }
  .font-semibold {
    --tw-font-weight: var(--font-weight-semibold);
    font-weight: var(--font-weight-semibold);
  }
  .text-white {
    color: var(--color-white);
  }
  @media (hover: hover) {
    .hover\:bg-blue-700:hover {
      background-color: var(--color-blue-700);
    }
  }
  @media (width >= 48rem) {
    .md\:px-6 {
      padding-inline: calc(var(--spacing) * 6);
    }
  }
}
@property --tw-font-weight {
  syntax: "*";
  inherits: false;
}
@layer properties {
  @supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))) {
    *, ::before, ::after, ::backdrop {
      --tw-font-weight: initial;
    }
  }
}
Resultlive, in your browser
What’s happening
  1. The scanner read btn.html (the @source line; source(none) turns off automatic detection so only that file counts) and found eight class names. The output contains exactly those eight utilities and nothing else: no px-5, no bg-red-600. A full Tailwind build is just as selective; it only adds a reset (Preflight) and more theme.
  2. Theme values are real custom properties on :root (--spacing reads 0.25rem in the test), and only the ones the utilities use are emitted. px-4 is calc(var(--spacing) * 4), so it computes to 16px; py-2 gives 8px; rounded-md gives 6px.
  3. Variants become ordinary CSS around the same declaration. md:px-6 is wrapped in @media (width >= 48rem) (the range syntax; 48rem is 768px), so at 360px wide the button has 16px inline padding and at 800px it has 24px. hover: is wrapped in @media (hover: hover) so touch screens don't get sticky hover colours; hover the live button on a desktop to see it.
  4. Colours are oklch(): bg-blue-600 computes to oklch(0.546 0.245 262.881) in Chrome. That's one reason Tailwind 4 needs modern browsers.
  5. Everything sits in cascade layers (theme, utilities, and a properties layer that fills in @property defaults for browsers where registered properties are incomplete, detected by that @supports test). Being layered means any unlayered CSS you write beats every utility, which is the gotcha below.
  6. The selector of each utility is one class, so every utility has specificity (0,1,0) (plus a pseudo-class for hover:). Utilities don't fight each other by specificity; two conflicting utilities on one element are decided by their order in the generated CSS, which is why you remove px-4 instead of adding px-6 next to it.
AdvancedPitfall: dynamic Tailwind class names

Gotcha: class names must appear whole

The scanner doesn't run your code. It reads files as text and picks out tokens that look like class names, so a class built from pieces at runtime is never generated. Real result for this file:

dyn.html
<!-- tone comes from data at runtime -->
<div class="bg-${tone}-600"></div>
<div class="{{ active ? 'bg-green-600' : 'bg-red-600' }}"></div>
Utilities in the output
.bg-green-600 { background-color: var(--color-green-600); }
.bg-red-600   { background-color: var(--color-red-600); }
What’s happening
  1. bg-${tone}-600 is not a class name in the text, and the scanner can't know what tone will be, so no bg-blue-600 or bg-amber-600 was generated. The element renders with no background, with no error anywhere.
  2. 'bg-green-600' and 'bg-red-600' appear as complete strings, so both were generated even though only one is used at a time.
  3. The fix: map data to complete class names in code (const toneClass = { info: "bg-blue-600", danger: "bg-red-600" }), or use a custom property for truly dynamic values (style="--tone: …" with bg-(--tone)). The scanner also skips files in .gitignore, so classes that only exist in an ignored or generated file need an explicit @source.
AdvancedCustom tokens with @theme and @apply

Tailwind 4 is configured in CSS, not in tailwind.config.js. @theme adds design tokens, and each one creates utilities and a runtime custom property. Real input and output (4.3.3):

theme.css (input)
@layer theme, components, utilities;
@import "tailwindcss/theme.css" layer(theme);
@import "tailwindcss/utilities.css" layer(utilities) source(none);
@source "./theme.html"; /* <a class="btn-brand bg-brand/80 w-[37px]"> */

@theme {
  --color-brand: #0f766e;
}

@layer components {
  .btn-brand {
    @apply rounded-md bg-brand px-4 py-2 text-white;
  }
}
theme.out.css (output, header and @property rules omitted)
@layer theme, components, utilities;
@layer theme {
  :root, :host {
    --color-white: #fff;
    --spacing: 0.25rem;
    --radius-md: 0.375rem;
    --color-brand: #0f766e;
  }
}
@layer utilities {
  .w-\[37px\] {
    width: 37px;
  }
  .bg-brand\/80 {
    background-color: color-mix(in srgb, #0f766e 80%, transparent);
    @supports (color: color-mix(in lab, red, red)) {
      background-color: color-mix(in oklab, var(--color-brand) 80%, transparent);
    }
  }
}
@layer components {
  .btn-brand {
    border-radius: var(--radius-md);
    background-color: var(--color-brand);
    padding-inline: calc(var(--spacing) * 4);
    padding-block: calc(var(--spacing) * 2);
    color: var(--color-white);
  }
}
What’s happening
  1. --color-brand in @theme made a new colour available to every colour utility: bg-brand, text-brand, border-brand. It is also emitted as a real custom property on :root, so plain CSS or JavaScript can read it.
  2. bg-brand/80 is the opacity modifier: it compiles to color-mix(in oklab, var(--color-brand) 80%, transparent), with an sRGB color-mix() fallback written before it and the oklab version guarded by @supports (that @supports is itself nested inside the rule, using native CSS nesting).
  3. w-[37px] is an arbitrary value: a one-off that isn't in the theme, generated on demand as .w-\[37px\] { width: 37px; }. Useful as an escape hatch; a codebase full of them has stopped using the design system.
  4. @apply copied the declarations of five utilities into .btn-brand. Note that .btn-brand was not generated as a utility from the HTML; it's an ordinary rule you wrote, in your own components layer. Because @layer theme, components, utilities; comes first, components sits below utilities, so a utility class on the same element still overrides .btn-brand even though the utilities block appears earlier in the file.
  5. Tailwind's own advice is to extract repeated markup into components or partials of your framework (a Button component) before reaching for @apply; @apply everywhere rebuilds the semantic-class CSS that Tailwind was meant to avoid.
AdvancedPitfall: unlayered CSS beats utilities

Gotcha: unlayered CSS beats every utility

Tailwind 4's output lives in cascade layers, and normal declarations in unlayered CSS beat every layer, whatever the specificity (see Cascade layers). An old global stylesheet loaded next to Tailwind can therefore win against utilities:

A legacy type selector beats a utility
CSS
/* excerpt of Tailwind's output */
@layer theme, utilities;
@layer theme { :root { --color-blue-600: oklch(54.6% 0.245 262.881); --spacing: 0.25rem; } }
@layer utilities {
  .bg-blue-600 { background-color: var(--color-blue-600); }
  .px-4 { padding-inline: calc(var(--spacing) * 4); }
}

/* legacy.css, not in a layer */
button { background-color: rgb(229, 231, 235); padding: 6px; }
HTML
<button class="bg-blue-600 px-4">Tailwind button</button>
Resultlive, in your browser
What’s happening
  1. .bg-blue-600 has specificity (0,1,0) and button has (0,0,1). Without layers, the class would win.
  2. But the cascade compares layers before specificity: for normal declarations, unlayered styles come after all layers in priority. So the legacy button rule wins: the background is rgb(229, 231, 235) and the inline padding 6px, not 16px.
  3. The fix is to put legacy or third-party CSS in a layer too, below utilities: @import url("legacy.css") layer(legacy); with @layer theme, base, legacy, components, utilities; declared first. Tailwind 3 didn't use native layers, so this bites teams during the 3 → 4 upgrade.
AdvancedWhat changed from Tailwind 3 to 4

Tailwind 3 vs Tailwind 4

Tailwind 4.0 (released 22 January 2025) is a rewrite: configuration moved from tailwind.config.js into CSS (@import "tailwindcss", @theme, @source, @utility); @tailwind base/components/utilities directives are gone; content paths are detected automatically (skipping .gitignored and binary files); the output uses native cascade layers, @property and color-mix(); and Lightning CSS handles prefixing, so no Autoprefixer is needed. Per Tailwind's compatibility page it targets Safari 16.4+, Chrome 111+ and Firefox 128+, and teams that need older browsers are told to stay on v3.4. A v3 config can still be loaded with @config. The React site's Tailwind CSS topic covers using it in components.

#CSS-in-JS

Added

CSS-in-JS means writing styles in JavaScript or TypeScript files, usually right next to the component, and letting a library turn them into real CSS with generated class names. It came out of the React ecosystem and solves the global namespace the same way CSS Modules do, and adds one thing they can't: styles can depend on props and state directly in the style definition.

There are two families, and the difference is when the CSS is generated. Runtime libraries (styled-components, Emotion) serialise and hash your styles in the browser while components render, inserting <style> rules as needed. Zero-runtime libraries (vanilla-extract, Linaria, Panda CSS, StyleX) evaluate the style files at build time and ship a static .css file plus a map of class names, like CSS Modules written in TypeScript. The mental model: runtime CSS-in-JS is a style printer that runs every time you render; zero-runtime is the same printer run once, in the build. Both outputs below are real: styled-components 6.6.0 rendered with react-dom/server 19.3.1, and vanilla-extract 1.21.3 built with esbuild 0.28.2.

Button.jsx
import styled from "styled-components";

export const Button = styled.button`
  padding: 8px 16px;
  border: 0;
  border-radius: 6px;
  color: #fff;
  background: ${(props) => (props.$tone === "danger" ? "#b91c1c" : "#2563eb")};

  &:hover {
    filter: brightness(0.9);
  }
`;
render.jsx
import { renderToString } from "react-dom/server";
import { ServerStyleSheet } from "styled-components";
import { Button } from "./Button.jsx";

const sheet = new ServerStyleSheet();
const html = renderToString(
  sheet.collectStyles(
    <>
      <Button>Save</Button>
      <Button $tone="danger">Delete</Button>
    </>,
  ),
);
console.log(html);
console.log(sheet.getStyleTags());
Output (line breaks added between tags)
<button class="sc-bdnzMm bILBri">Save</button>
<button class="sc-bdnzMm zztjo">Delete</button>

<style data-styled="true" data-styled-version="6.6.0">.bILBri{padding:8px 16px;border:0;border-radius:6px;color:#fff;background:#2563eb;}/*!sc*/
.bILBri:hover{filter:brightness(0.9);}/*!sc*/
.zztjo{padding:8px 16px;border:0;border-radius:6px;color:#fff;background:#b91c1c;}/*!sc*/
.zztjo:hover{filter:brightness(0.9);}/*!sc*/
data-styled.g1[id="sc-bdnzMm"]{content:"bILBri,zztjo,"}/*!sc*/
</style>
What’s happening
  1. styled.button returns a component. Its template literal is the CSS, and the ${(props) => …} hole is a function the library calls with each element's props during render.
  2. Each button gets two classes. sc-bdnzMm is the component id, the same for every Button; it carries no styles and is used for targeting and rehydration. bILBri and zztjo are hashes of the evaluated CSS text.
  3. Because the two buttons' props produced different CSS (#2563eb vs #b91c1c), the library generated two complete rules: every declaration, including the :hover rule, is duplicated with only the background different. A prop with ten possible values creates up to ten copies of the whole block.
  4. In the browser the same work (evaluate interpolations, serialise, hash, check the cache, insert a rule) happens during render on the client. That is the runtime cost: JavaScript that must download and run before styles exist, extra work on every render with new prop values, and style insertion that can force the browser to recalculate styles mid-render.
  5. On the server, ServerStyleSheet collects every rule generated during renderToString so the HTML ships with its <style> tag (no flash of unstyled content). The odd data-styled.g1[id="sc-bdnzMm"]{content:"bILBri,zztjo,"} rule matches nothing; it's a record of which classes were already generated, read back when the client takes over.
Advancedstyled-components SSR output rendered live

The server output, rendered

styled-components' SSR output in a page
CSS
.bILBri{padding:8px 16px;border:0;border-radius:6px;color:#fff;background:#2563eb;}/*!sc*/
.bILBri:hover{filter:brightness(0.9);}/*!sc*/
.zztjo{padding:8px 16px;border:0;border-radius:6px;color:#fff;background:#b91c1c;}/*!sc*/
.zztjo:hover{filter:brightness(0.9);}/*!sc*/
data-styled.g1[id="sc-bdnzMm"]{content:"bILBri,zztjo,"}/*!sc*/
HTML
<button class="sc-bdnzMm bILBri">Save</button><button class="sc-bdnzMm zztjo">Delete</button>
Resultlive, in your browser
What’s happening
  1. Once on the page it's ordinary CSS: "Save" is rgb(37, 99, 235) and "Delete" rgb(185, 28, 28), both with 16px inline padding, which comes from two separate copies of the same declarations.
  2. No rule targets .sc-bdnzMm; the test confirms the component id is styling-free.
  3. data-styled.g1[id="sc-bdnzMm"] parses as a valid selector (an element named data-styled with class g1), matches nothing in the page, and only exists as data. Rendered by hand like this, without the library, there's no runtime cost at all, which is exactly what zero-runtime libraries automate.
AdvancedZero-runtime CSS-in-JS with vanilla-extract

Zero-runtime: vanilla-extract

vanilla-extract style files are TypeScript (*.css.ts) that run at build time. The build writes a static CSS file and replaces the import with plain strings.

button.css.ts
import { style, styleVariants } from "@vanilla-extract/css";

export const base = style({
  padding: "8px 16px",
  border: 0,
  borderRadius: 6,
  color: "#fff",
  ":hover": { filter: "brightness(0.9)" },
});

export const tone = styleVariants({
  info: [base, { background: "#2563eb" }],
  danger: [base, { background: "#b91c1c" }],
});
app.ts
import { tone } from "./button.css.ts";

console.log(tone.info, "|", tone.danger);
app.css (extracted by the build)
.button_base__1tby6dv0 {
  padding: 8px 16px;
  border: 0;
  border-radius: 6px;
  color: #fff;
}
.button_base__1tby6dv0:hover {
  filter: brightness(0.9);
}
.button_tone_info__1tby6dv1 {
  background: #2563eb;
}
.button_tone_danger__1tby6dv2 {
  background: #b91c1c;
}
app.js (bundled, excerpt)
var tone = {
  info: "button_tone_info__1tby6dv1 button_base__1tby6dv0",
  danger: "button_tone_danger__1tby6dv2 button_base__1tby6dv0"
};
console.log(tone.info, "|", tone.danger);
What’s happening
  1. The esbuild plugin executed button.css.ts during the build, collected every style() call and wrote them to app.css. The bundle contains no vanilla-extract code; tone is now a plain object of strings, and running it printed button_tone_info__1tby6dv1 button_base__1tby6dv0 | button_tone_danger__1tby6dv2 button_base__1tby6dv0.
  2. Compare with styled-components: the shared declarations exist once (.button_base__…) and each variant adds only its background, like CSS Modules' composes. Variants must be known at build time; a truly dynamic value goes through a CSS variable (vanilla-extract's createVar and assignInlineVars).
  3. borderRadius: 6 became 6px, and the camelCase keys became CSS properties. You get typed style objects (a typo in a property name is a TypeScript error) with the runtime profile of a plain .css file. The cost is a build plugin and the build-time-only constraint.
AdvancedServer Components and maintenance mode

Where CSS-in-JS stands in 2026

Runtime CSS-in-JS doesn't fit React Server Components: a library that generates styles during render needs client-side context, so styled-components and Emotion components must be client components ('use client'). styled-components' maintainer put the library into maintenance mode in March 2025 and advised against adopting it for new projects (it still receives fixes, and a 2026 update describes an RSC-compatible setup). The trend is toward zero-runtime tools (vanilla-extract, Panda CSS, StyleX from Meta, Linaria) or away from CSS-in-JS altogether (CSS Modules, Tailwind). React-specific details, including useInsertionEffect for library authors, are on the React site's CSS-in-JS: costs and alternatives.

#PostCSS, Autoprefixer and browser support

Added

PostCSS is not a preprocessor with its own language; it's a CSS parser plus a plugin pipeline. It parses CSS into a tree (rules, declarations, at-rules), hands the tree to each plugin in turn, and prints the result. Everything interesting is in the plugins: Autoprefixer adds vendor prefixes, postcss-preset-env rewrites modern syntax for older browsers, postcss-modules implements CSS Modules, and Tailwind 3 was a PostCSS plugin. Vite, Next.js and most build tools run PostCSS automatically when they find a postcss.config.js.

The mental model is Babel for CSS: you write the CSS of today, and a tool rewrites it for the browsers you've said you support. That list of browsers comes from Browserslist, a shared config ("browserslist" in package.json or a .browserslistrc) read by Autoprefixer, postcss-preset-env, Lightning CSS and Babel alike, so CSS and JavaScript target the same browsers. Below is the same input through Autoprefixer 10.6.1 (PostCSS 8.5.31, Browserslist 4.29.4, caniuse-lite 1.0.30001817) for an old target list.

input.css
.card {
  display: flex;
  user-select: none;
  backdrop-filter: blur(4px);
  mask-image: linear-gradient(#000, transparent);
}

.field::placeholder {
  color: #6b7280;
}

.title {
  text-size-adjust: 100%;
  hyphens: auto;
}
Output for "safari 12, ie 11, firefox 60"
.card {
  display: flex;
  -webkit-user-select: none;
     -moz-user-select: none;
      -ms-user-select: none;
          user-select: none;
  -webkit-backdrop-filter: blur(4px);
          backdrop-filter: blur(4px);
  -webkit-mask-image: linear-gradient(#000, transparent);
          mask-image: linear-gradient(#000, transparent);
}

.field:-ms-input-placeholder {
  color: #6b7280;
}

.field::placeholder {
  color: #6b7280;
}

.title {
  text-size-adjust: 100%;
  -webkit-hyphens: auto;
      -ms-hyphens: auto;
          hyphens: auto;
}
What’s happening
  1. Autoprefixer looks up every property, value and selector in the caniuse database for each target browser and adds a prefixed copy only where some target needs it. display: flex needs nothing in these browsers, so it's untouched.
  2. user-select gets three prefixed copies: -webkit- (Safari 12), -moz- (Firefox 60) and -ms- (IE 11). The prefixed versions come first and the standard property last, so a browser that understands both uses the standard one (later declarations win).
  3. ::placeholder can't simply be prefixed inside one rule, because a browser drops a whole rule if any selector in it is unknown. So Autoprefixer writes a separate rule for IE's :-ms-input-placeholder.
  4. text-size-adjust got no prefix here, yet with today's defaults (below) it gets -webkit- and -moz-. Prefixes depend entirely on the target list, which is why you never write them by hand.
  5. The rewritten CSS is what ships. PostCSS's job ended at the build; nothing runs in the browser.
AdvancedPrefixes for defaults and Baseline targets

The same input for modern targets

Browserslist's defaults query is > 0.5%, last 2 versions, Firefox ESR, not dead. Since 2025 Browserslist also understands Baseline queries such as baseline widely available (announced on web.dev in September 2025; 4.29.4, used here, supports them). Real output:

Output for "defaults"
.card {
  display: flex;
  -webkit-user-select: none;
     -moz-user-select: none;
          user-select: none;
  backdrop-filter: blur(4px);
  -webkit-mask-image: linear-gradient(#000, transparent);
          mask-image: linear-gradient(#000, transparent);
}

.field::-moz-placeholder {
  color: #6b7280;
}

.field::placeholder {
  color: #6b7280;
}

.title {
  -webkit-text-size-adjust: 100%;
     -moz-text-size-adjust: 100%;
          text-size-adjust: 100%;
  hyphens: auto;
}
Output for "baseline widely available"
.card {
  display: flex;
  -webkit-user-select: none;
          user-select: none;
  -webkit-backdrop-filter: blur(4px);
          backdrop-filter: blur(4px);
  mask-image: linear-gradient(#000, transparent);
}

.field::placeholder {
  color: #6b7280;
}

.title {
  -webkit-text-size-adjust: 100%;
     -moz-text-size-adjust: 100%;
          text-size-adjust: 100%;
  hyphens: auto;
}
What’s happening
  1. On 20 October 2026, npx browserslist defaults lists Chrome 150–155 plus Chrome 109 (still above 0.5% usage), Edge 152–155, Firefox 153 and 155–158, Safari 26.6 and 27, iOS Safari 18.5–18.7, 26.5, 26.6 and 27.0, Samsung Internet 29–30, Opera Mini, KaiOS 2.5 and 3.0–3.1 and a few others (Browserslist reports 86.04% global coverage). Running Autoprefixer for single entries shows who needs what: Chrome 109 needs -webkit-mask-image, and KaiOS 2.5 (an old Firefox-based phone OS) is the reason for -moz-user-select and the ::-moz-placeholder rule.
  2. baseline widely available targets the browsers that support every feature that has been interoperable for 30 months: here Chrome and Edge 124+, Firefox 125+, Safari and iOS Safari 17.4+. That range reaches back to Safari 17, so backdrop-filter needs -webkit- again, while Firefox's placeholder prefix drops out.
  3. The lesson: the output is a function of the query. Teams that write their own query (> 0.2%, not dead, safari >= 15) should review it every year, because "last 2 versions" moves on its own as browsers ship, while a pinned version doesn't.
  4. caniuse-lite data ages: when it's old, Browserslist prints a warning to run npx update-browserslist-db@latest. Stale data means wrong prefixes.
AdvancedLowering nesting and oklch with preset-env

Lowering modern syntax: postcss-preset-env

Prefixes aren't the only gap. postcss-preset-env (11.6.1) rewrites newer syntax that older targets can't parse, such as nesting and oklch(). Real output for two target lists:

modern.css
.card {
  color: oklch(45% 0.15 260);

  & .title {
    font-weight: 700;
  }

  &:hover {
    color: oklch(35% 0.12 260);
  }
}
Output for "safari 15, chrome 100"
.card {
  color: rgb(28, 80, 167);
}
.card .title {
    font-weight: 700;
  }
.card:hover {
    color: rgb(15, 55, 120);
  }
Output for "chrome 130, safari 18, firefox 130"
.card {
  color: oklch(45% 0.15 260);

  & .title {
    font-weight: 700;
  }

  &:hover {
    color: oklch(35% 0.12 260);
  }
}
What’s happening
  1. Safari 15 and Chrome 100 support neither native nesting nor oklch(), so the preset flattens the nesting into .card .title and .card:hover and converts both colours to rgb(). (The odd indentation is real; the plugin moves rules without reformatting them.)
  2. For the modern list every feature is supported, so the output is the input, untouched. Like Autoprefixer, the preset only does work the targets require.
  3. Converting oklch() to rgb() is lossless only for colours inside the sRGB gamut. For colours outside it, the preset adds a @media (color-gamut: p3) / @supports block with a display-p3 version, so wide-gamut screens still get the more vivid colour.
The flattened output works the same
CSS
.card {
  color: rgb(28, 80, 167);
}
.card .title {
    font-weight: 700;
  }
.card:hover {
    color: rgb(15, 55, 120);
  }
HTML
<div class="card"><span class="title">Title</span> and body text</div>
Resultlive, in your browser
What’s happening
  1. The flattened CSS gives the card rgb(28, 80, 167) and the nested title font-weight: 700, the same result the nested source gives in a browser that supports nesting.
  2. To check the colour conversion, the test paints oklch(45% 0.15 260) onto a canvas in Chrome and reads the pixel back: it comes out as 28, 80, 167 (within 1), the value the preset wrote. oklch(35% 0.12 260) reads back as 15, 55, 120.
  3. So the transform kept the meaning and changed only the spelling, which is the job of every PostCSS plugin in this family.
AdvancedWhy prefixed selectors get their own rule

Why the placeholder rule is split in two

Step 3 above says Autoprefixer writes :-ms-input-placeholder as a separate rule. Here's why, in Chrome:

One unknown selector kills the whole rule
CSS
.joined::placeholder, .joined:-ms-input-placeholder { color: rgb(185, 28, 28); }
.split::placeholder { color: rgb(185, 28, 28); }
.split:-ms-input-placeholder { color: rgb(185, 28, 28); }
HTML
<input class="joined" placeholder="Joined selector list">
<input class="split" placeholder="Separate rules">
Resultlive, in your browser
What’s happening
  1. Chrome doesn't know :-ms-input-placeholder. In a selector list, one invalid selector makes the whole rule invalid, so .joined::placeholder, .joined:-ms-input-placeholder is dropped entirely, including the part Chrome understands; its placeholder keeps Chrome's default rgb(117, 117, 117).
  2. With separate rules, only the IE rule is dropped (the stylesheet keeps just .split::placeholder), and the second input's placeholder is rgb(185, 28, 28).
  3. That's why prefixed selectors always get their own rule. The forgiving exceptions are :is() and :where(), which ignore invalid selectors inside them (see :is() and :where()).
AdvancedLightning CSS and limits of prefixing

Lightning CSS, and what prefixing can't fix

#Resets and normalize

Added

Every browser ships a user-agent stylesheet: the defaults that make an unstyled page readable. body gets an 8px margin, headings get sizes and margins, lists get 40px of left padding and bullets, buttons get a system font that does not inherit the page's. Those defaults are useful for documents and in the way for applications, and they have historically differed slightly between browsers. A reset stylesheet sets a known baseline before your own CSS.

There are three philosophies. A hard reset (Eric Meyer's reset, 2008–2011) zeroes almost everything, so h1 and p look identical until you style them. normalize.css keeps the useful defaults and only fixes cross-browser inconsistencies; its last release, 8.0.1, is from November 2018, because browsers have since converged. Modern resets (Josh Comeau's, Andy Bell's, Tailwind's Preflight, modern-normalize) are small and opinionated: box-sizing: border-box everywhere, no default margins, media as blocks, form controls that inherit fonts. The mental model is wiping a whiteboard before a meeting: you don't erase the grid lines (normalize keeps them), but you do remove whatever the last person left (margins you didn't ask for).

The same markup without and with a modern reset
CSS
.col { display: inline-block; vertical-align: top; width: 260px; margin-right: 8px; border: 1px dashed rgb(148, 163, 184); }
.frame { background: rgb(224, 231, 255); }

/* a modern reset, scoped to .reset for the comparison */
.reset *, .reset *::before, .reset *::after { box-sizing: border-box; }
.reset * { margin: 0; }
.reset ul[role="list"] { padding: 0; list-style: none; }
.reset img, .reset svg { display: block; max-width: 100%; }
.reset input, .reset button, .reset textarea, .reset select { font: inherit; }
HTML
<div class="col">
  <h3>Defaults</h3>
  <ul role="list"><li>Item</li></ul>
  <div class="frame"><img alt="" width="60" height="40" src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='60' height='40'%3E%3Crect width='60' height='40' fill='%2393c5fd'/%3E%3C/svg%3E"></div>
  <button>Button</button>
</div>
<div class="col reset">
  <h3>Reset</h3>
  <ul role="list"><li>Item</li></ul>
  <div class="frame"><img alt="" width="60" height="40" src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='60' height='40'%3E%3Crect width='60' height='40' fill='%2393c5fd'/%3E%3C/svg%3E"></div>
  <button>Button</button>
</div>
Resultlive, in your browser
What’s happening
  1. The UA stylesheet gives the h3 a margin of 1em of its own font size: 17.55px (the page's 15px × 1.17). Inside .reset it's 0px. With margins zeroed, spacing becomes something you add on purpose (with gap, or margins on specific elements) instead of something you fight.
  2. The default list has 40px of left padding and disc bullets; the reset removes both, but only for lists marked role="list" (Andy Bell's convention), so ordinary content lists keep their bullets.
  3. The UA button doesn't inherit typography: it computes to 13.3333px in a system font different from the page's. With font: inherit the reset button is 15px in the page's own font family, like the text around it. This is one of the most visible fixes a reset makes.
  4. The image frames show the classic gap. An img is inline by default, so it sits on the text baseline and the line box leaves room below it for descenders: the default frame is taller than the 40px image. With display: block, the reset frame is exactly 40px.
  5. Removing bullets has an accessibility side effect in one browser: WebKit deliberately stops exposing some lists without visible markers as lists to VoiceOver (WebKit bug 170179; still reported on iOS in December 2025). Chrome keeps the role (computedRole is "list", tested here), and adding role="list" restores it in Safari, which is why the reset targets ul[role="list"].
AdvancedInside a modern reset, and where it goes

What a modern reset contains, and why

reset.css
*, *::before, *::after { box-sizing: border-box; }   /* widths include padding and border */
* { margin: 0; }                                     /* no inherited spacing decisions */
html { -webkit-text-size-adjust: 100%; }             /* stop iOS inflating text on rotate */
body { min-height: 100svh; line-height: 1.5; }       /* readable default line height */
img, picture, video, canvas, svg { display: block; max-width: 100%; }
input, button, textarea, select { font: inherit; }   /* form controls match the page */
p, h1, h2, h3, h4, h5, h6 { overflow-wrap: break-word; }
h1, h2, h3 { text-wrap: balance; }                    /* nicer heading line breaks */
What’s happening
  1. box-sizing: border-box is the single most common line in every modern reset, because it makes width: 200px mean the visible box is 200px including padding and border (see box-sizing).
  2. Margins go to zero so that components don't carry spacing that only made sense in a document. Tailwind's Preflight goes further: it also zeroes padding and borders on every element and sets headings to font-size: inherit and font-weight: inherit, so an h1 looks like body text until you add utilities.
  3. Media become blocks with max-width: 100% so images never overflow their container and don't leave the baseline gap from the demo.
  4. font: inherit on form controls fixes the 13.3333px system font from the demo.
  5. min-height: 100svh uses the small viewport unit so the body fills the screen on phones without jumping when browser toolbars show and hide (see Viewport units on phones).

#Choosing an approach

Added

There's no single right answer, and interviewers want to hear trade-offs, not loyalty. The question to ask is where the source of truth for a component's styles lives and when its CSS is produced: in a shared stylesheet with a naming convention, in a scoped file next to the component, in the markup as utilities, or in JavaScript at build time or at runtime. Every option is solving the global-cascade problem from the first topic in a different place.

In 2026 the platform itself covers much of what tools were invented for: cascade layers give the cascade an explicit order, @scope limits where selectors apply, native nesting and custom properties replace most preprocessor use. A common modern stack is plain CSS or CSS Modules organised with layers and design tokens as custom properties, or Tailwind with its theme; runtime CSS-in-JS is mostly found in existing codebases.

ApproachScopingRuntime costDynamic valuesBest forWatch out for
Plain CSS + BEMBy conventionNoneCustom propertiesSmall teams, server-rendered sites, design systems shipped as CSSDiscipline; global by default
CSS ModulesGenerated class namesNoneCustom properties, inline stylesComponent apps that want real CSSOnly classes are scoped; typos are silent
Sass/LessNone by itself (combine with BEM or Modules)NoneCompile-time onlyLarge token sets, mixins, legacy codebasesVariables are constants; @import deprecated
TailwindNo new selectors at allNoneArbitrary values, bg-(--var)Product teams moving fast on a design systemLong class lists; whole class names only; layer conflicts with legacy CSS
Runtime CSS-in-JSGenerated class namesJS + style injection on renderProps directlyExisting styled-components or Emotion appsServer Components, performance, maintenance status
Zero-runtime CSS-in-JSGenerated class namesNoneCSS variablesType-safe design systemsBuild plugin; build-time values only

Whatever you pick, the cascade needs an order. Layers let you declare it once, for every file and tool:

One declared layer order for the whole app
CSS
@layer reset, base, vendor, components, utilities;

@layer reset { .app * { margin: 0; } }
@layer base { .app a { color: rgb(29, 78, 216); } .app p { margin-bottom: 8px; } }
@layer vendor { .widget a { color: rgb(126, 34, 206); font-weight: 700; } }
@layer components { .card a { color: rgb(15, 118, 110); } .card { padding: 12px; border: 1px solid rgb(203, 213, 225); } }
@layer utilities { .text-danger { color: rgb(185, 28, 28); } .p-0 { padding: 0; } }
HTML
<div class="app">
  <p><a href="#x">Base link</a></p>
  <p class="widget"><a href="#x">Vendor widget link</a></p>
  <div class="card"><a href="#x">Card link</a> and <a class="text-danger" href="#x">danger link</a></div>
  <div class="card p-0"><a href="#x">Card without padding</a></div>
</div>
Resultlive, in your browser
What’s happening
  1. The first line, @layer reset, base, vendor, components, utilities;, fixes the priority order once: later layers win. Every rule that follows only says which layer it belongs to, so the file order of the blocks (or of separate files) no longer matters.
  2. The base link is rgb(29, 78, 216), and paragraphs keep their 8px bottom margin even though the reset's .app * set margin: 0: base is a later layer than reset.
  3. The vendor widget's link is purple, rgb(126, 34, 206): a third-party stylesheet, imported with @import url(widget.css) layer(vendor), sits above your base styles but below your components, so it can't override your design system by accident.
  4. The danger link shows the key property of layers: .text-danger (0,1,0) beats .card a (0,1,1) and renders rgb(185, 28, 28), because layer order is compared before specificity. Utilities always win over components without !important. Likewise .p-0 takes the second card's padding from 12px to 0px.
  5. This is ITCSS's inverted triangle, enforced by the browser instead of by file-ordering discipline, and it works the same whether the CSS inside each layer is BEM, CSS Modules output or Tailwind's (whose output already uses theme, base, components and utilities layers).
AdvancedQuestions that decide the choice

Written fresh for interviews (there was no Google Doc for this one). Every live example was checked in headless Chrome 155 by scripts/check-demos.mjs.

RohitDownloads · All chapters

Sync progress across devices

Type the same private phrase on your Mac and your phone, and your Learned ticks follow you between them — on every study site.

The phrase never leaves this device: only a fingerprint of it is sent, and the server stores a fingerprint of that. Anyone who knows the phrase could see or change your ticks, so pick something you don’t use elsewhere.

Sync is on in this browser.

Sync ID

This ID must be the same on every device. If another device shows a different one, its phrase is different (capital letters count): tap “Turn off here” on it and type the phrase again exactly.