HTML & CSS NotesRohit’s interview study guide
Chapter 09

Modern CSS

Custom properties and theming, native nesting, logical properties, math and colour functions, better line wrapping, feature detection, and a round-up of what CSS gained since 2023 with its Baseline status as of October 2026.

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

#Custom properties (CSS variables)

Added

A custom property is any property whose name starts with two dashes, like --accent. The browser doesn't know what it means, so it doesn't check the value: it stores it as written and lets you pull it into real properties with var(--accent). Unlike almost every other property, custom properties inherit by default, so a value set on :root is visible everywhere, and a value set on one element is visible to all of its descendants.

The mental model that matters in interviews: these are not Sass variables. A Sass variable is replaced by its value when the stylesheet is compiled, and after that it no longer exists. A custom property is a live property on every element in the DOM. It goes through the cascade like color does, can be overridden for one subtree, changed by a media query or a class, and read or written by JavaScript at runtime, and every var() that depends on it updates.

var() is resolved at computed-value time, per element: the browser looks up the element's own value of --accent (inherited from the nearest ancestor that set it), pastes it into the declaration, and only then parses the result. That one fact explains the features (overriding per component) and the surprising failures (see the quiz below).

Tokens on :root, overridden for one card
CSS
:root {
  --accent: #2563eb;
  --space: 8px;
}
.card {
  width: 200px;
  margin-bottom: 8px;
  padding: calc(var(--space) * 2);
  border: 2px solid var(--accent);
  color: var(--accent);
}
.card.danger {
  --accent: #dc2626;
}
.badge {
  background: var(--accent);
  color: #fff;
  padding: 0 var(--space);
}
.note {
  width: var(--note-width, 150px);
  background: #f1f5f9;
}
HTML
<div class="card">Default card <span class="badge">new</span></div>
<div class="card danger">Danger card <span class="badge">!</span></div>
<div class="note">fallback width</div>
Resultlive, in your browser
What’s happening
  1. :root (the <html> element) sets --accent: #2563eb and --space: 8px. Because custom properties inherit, every element on the page now has those two values unless something closer overrides them.
  2. The first card resolves color: var(--accent) to rgb(37, 99, 235) and padding: calc(var(--space) * 2) to 16px. The substitution happens per element, at computed-value time.
  3. .card.danger sets its own --accent: #dc2626. It changes nothing about .card's declarations: the same color: var(--accent) now finds a different value on this element and computes to rgb(220, 38, 38).
  4. The badges show inheritance at work: each .badge has no --accent of its own, so it inherits from its card. The first badge's background is rgb(37, 99, 235), the second's is rgb(220, 38, 38). This is how one component can be re-themed by setting a single property on its root.
  5. .note uses var(--note-width, 150px). Nothing defines --note-width, so the fallback after the comma is used: 150px.
  6. Reading --accent back with getComputedStyle gives "#2563eb", not rgb(...): an unregistered custom property's computed value is the text you wrote (trimmed), because the browser doesn't know it's a colour. color, a real property, computes to rgb(37, 99, 235).
  7. The test then sets --space to 12px on :root from JavaScript (the code below): the card's padding becomes 24px and the badge's side padding 12px with no other change, and removing it brings back 16px. That live update is what a preprocessor variable can't do.
AdvancedReading and writing them from JavaScript

The JavaScript side is just the normal style APIs. The last lines of the demo's test run exactly this against the cards above:

JavaScript
const root = document.documentElement;

// Read: the computed value, inherited or not
getComputedStyle(root).getPropertyValue("--accent"); // "#2563eb"

// Write: inline on an element (here :root), so it wins over the stylesheet
root.style.setProperty("--space", "12px"); // every var(--space) updates
root.style.removeProperty("--space");      // back to the stylesheet value
Invalid at computed-value time
CSS
:root { --size: 20px; }
.parent { color: green; }
.child  { color: blue; color: var(--size); }
HTML
<div class="parent"><p class="child">What colour am I?</p></div>

What colour is the paragraph's text?

Show answer

Green, not blue. var(--size) produces 20px, which isn't a colour, and by then it's too late to go back to color: blue.

The declaration that wins, then fails
CSS
:root { --size: 20px; }
.parent { color: green; }
.child { color: blue; color: var(--size); }
.boxed { width: 120px; width: var(--size-word); --size-word: wide; height: 10px; background: #fde68a; }
HTML
<div class="parent"><p class="child">What colour am I?</p></div>
<div class="boxed"></div>
Resultlive, in your browser
What’s happening
  1. At parse time the browser can't know what var(--size) will become, so it accepts color: var(--size) as valid. It's the later of the two color declarations, so the cascade picks it and throws color: blue away.
  2. At computed-value time the browser substitutes 20px, gets color: 20px, and finds it invalid. This is called invalid at computed-value time.
  3. The rule for that case: the property behaves as if it were set to unset. color is an inherited property, so unset means inherit, and the paragraph takes its parent's colour: measured rgb(0, 128, 0), green.
  4. For a property that doesn't inherit, unset means initial. The yellow .boxed bar shows it: width: var(--size-word) with --size-word: wide loses the earlier width: 120px and computes to auto, which here is the full 616px of the frame.
  5. The fix is to keep custom properties to one type, or to register the property with @property and a syntax (below), so an invalid value falls back to the registered initial value instead.
AdvancedTyped custom properties with @property

Registering a property with @property

@property tells the browser what type a custom property has, whether it inherits and what its initial value is. Once registered, the value is parsed and computed like a real property: lengths are resolved, angles converted, and an invalid value is rejected in favour of the initial value. Registered properties are Baseline 2024 (newly available since July 2024, when Firefox 128 shipped them).

A registered angle
CSS
@property --angle {
  syntax: "<angle>";
  inherits: false;
  initial-value: 0deg;
}
.dial {
  --angle: 0.25turn;
  width: 60px;
  height: 60px;
  border-radius: 50%;
  background: conic-gradient(#2563eb var(--angle), #e2e8f0 0);
}
.dial.bad { --angle: red; }
.spin { transition: --angle 1000ms linear; }
.spin.on { --angle: 180deg; }
.plain { --size: 10px; transition: --size 1000ms linear; }
@property --nope { syntax: "<length>"; initial-value: 0px; }
.dial { --nope: 1em; }
.plain.on { --size: 90px; }
HTML
<div class="dial"><span class="inner">child</span></div>
<div class="dial bad"></div>
<div class="dial spin"></div>
<div class="plain"></div>
Resultlive, in your browser
What’s happening
  1. @property --angle registers the name with syntax: "<angle>", inherits: false and initial-value: 0deg. All three descriptors are required for the rule to be valid (initial-value may only be left out when the syntax is "*"). The test also registers a --nope without inherits: that rule is ignored, and --nope: 1em stays plain unregistered text, "1em".
  2. .dial sets --angle: 0.25turn. Because the browser now knows it's an angle, it computes it like rotate would: getComputedStyle returns 90deg, not the text 0.25turn.
  3. .dial.bad sets --angle: red. For a registered property that's simply invalid, so the declaration is ignored and the value is the initial 0deg. Compare the quiz above, where the unregistered version broke the property that used it.
  4. inherits: false means the .inner span inside the first dial does not get 90deg; it has the initial 0deg.
  5. The big win is animation. .spin transitions --angle; after adding .on there is a real CSSTransition for --angle, and seeking it to 500 ms of 1000 ms gives 135deg, half way from 90deg to 180deg. The conic gradient that uses it animates with it, which isn't possible by transitioning background itself (gradients don't interpolate).
  6. The unregistered --size on .plain gets no transition at all (0 animations) and jumps straight to 90px: the browser can't interpolate a value it only knows as text.

The same registration is available from JavaScript as CSS.registerProperty({ name: "--angle", syntax: "<angle>", inherits: false, initialValue: "0deg" }).

AdvancedGotchas: names, whitespace and where var() works

#Theming and dark mode with custom properties

Added

Theming is the best use of custom properties. The idea is a two-layer system: components never use raw colours, only semantic tokens like --bg, --text and --surface, and a theme is nothing more than a different set of values for those tokens. Switching theme then means changing a few properties on one element; every component below it follows.

Dark mode adds two browser features on top. color-scheme tells the browser which colour schemes an element supports, so its own UI follows: form controls, scrollbars, the default page background and system colours like Canvas and CanvasText switch to dark versions. And prefers-color-scheme is the media feature that reports the user's operating-system setting. light-dark() ties them together: it picks one of two colours based on the element's colour scheme.

Mental model: tokens are paint pots with labels ("background", "text"); a theme refills the pots; color-scheme tells the browser to repaint its own furniture to match.

One set of components, two themes
CSS
:root,
[data-theme="light"] {
  --bg: #ffffff;
  --text: #1f2430;
  --surface: #eef2f7;
  color-scheme: light;
}
[data-theme="dark"] {
  --bg: #111827;
  --text: #e5e7eb;
  --surface: #1f2937;
  color-scheme: dark;
}
.panel {
  width: 260px;
  margin-bottom: 8px;
  padding: 10px;
  border: 1px solid #94a3b8;
  background: var(--bg);
  color: var(--text);
}
.panel .card {
  padding: 8px;
  background: var(--surface);
}
.panel input {
  width: 120px;
}
HTML
<div class="panel" id="inherits">
  <div class="card">Follows :root <input value="input"></div>
</div>
<div class="panel" data-theme="dark">
  <div class="card">data-theme="dark" <input value="input"></div>
</div>
Resultlive, in your browser
What’s happening
  1. :root, [data-theme="light"] defines the light tokens and color-scheme: light; [data-theme="dark"] defines the same token names with dark values and color-scheme: dark. The components (.panel, .card) only ever say var(--bg), var(--text) and var(--surface).
  2. The first panel has no data-theme, so it inherits the :root tokens: background rgb(255, 255, 255), text rgb(31, 36, 48), card rgb(238, 242, 247).
  3. The second panel has data-theme="dark", which redefines the tokens for that subtree only: background rgb(17, 24, 39), text rgb(229, 231, 235), card rgb(31, 41, 55). Same CSS for the components, different values.
  4. Look at the inputs. Nothing styles their colours, yet the one in the dark panel has a rgb(59, 59, 59) background and white text, while the light one is white with black text. That's color-scheme: dark at work: it switches the browser's built-in rendering for that subtree. Without it you'd get a bright white input inside a dark panel.
  5. Finally the test sets data-theme="dark" on <html> (what a toggle button does): the first panel, which inherits from :root, turns rgb(17, 24, 39) and its input dark too, and setting it back to light restores white. One attribute on one element re-themed everything below it.
AdvancedFollowing the OS with prefers-color-scheme

Following the operating system

The demo above only switches when something sets data-theme. To follow the user's system setting by default, redefine the tokens inside @media (prefers-color-scheme: dark). The live result below follows your setting; the test checks the panel against whatever matchMedia reports. (When this page was checked, the Mac running headless Chrome was set to dark, so the test saw the dark values.)

Tokens that follow the OS
CSS
.auto {
  --bg: #ffffff;
  --text: #1f2430;
  color-scheme: light;
  width: 260px;
  padding: 10px;
  border: 1px solid #94a3b8;
  background: var(--bg);
  color: var(--text);
}
@media (prefers-color-scheme: dark) {
  .auto {
    --bg: #111827;
    --text: #e5e7eb;
    color-scheme: dark;
  }
}
HTML
<div class="auto">I follow your system's light or dark setting.</div>
Resultlive, in your browser
What’s happening
  1. The light tokens are the default; the media query redefines them when the OS reports dark. prefers-color-scheme reads the system (or browser) setting; there is no way to set it from CSS.
  2. The test reads matchMedia("(prefers-color-scheme: dark)").matches and expects rgb(17, 24, 39) and color-scheme: dark when it's true, white and light otherwise. On the machine that checked this page it was true.
  3. A real site combines both: OS preference by default, and a data-theme attribute (written by a toggle) that overrides it. Put the [data-theme] rules after the media query, or give them higher specificity, so an explicit choice wins.
Advancedlight-dark(): both colours in one declaration

light-dark()

light-dark(<light>, <dark>) returns its first colour when the element's used colour scheme is light and the second when it's dark. It saves writing every token twice, but it only works when color-scheme allows dark: with color-scheme: light or normal, it always returns the light value. It's Baseline 2024 (newly available since May 2024); the image form, light-dark(url(a.png), url(b.png)), only became Baseline in September 2026 (Safari 27).

light-dark() decides per element
CSS
.box {
  width: 260px;
  margin-bottom: 6px;
  padding: 6px 10px;
  color: light-dark(#1f2430, #e5e7eb);
  background: light-dark(#ffffff, #111827);
  border: 1px solid #94a3b8;
}
.light { color-scheme: light; }
.dark { color-scheme: dark; }
.both { color-scheme: light dark; }
.normal { color-scheme: normal; }
.canvas { background: Canvas; color: CanvasText; }
HTML
<div class="box light">color-scheme: light</div>
<div class="box dark">color-scheme: dark</div>
<div class="box both">color-scheme: light dark (follows your OS)</div>
<div class="box normal">color-scheme: normal</div>
<div class="box dark canvas">dark, with the system colours Canvas / CanvasText</div>
Resultlive, in your browser
What’s happening
  1. Every box has the same two light-dark() declarations; only color-scheme differs.
  2. .light gets the first values (rgb(31, 36, 48) on white); .dark gets the second (rgb(229, 231, 235) on rgb(17, 24, 39)).
  3. .both says "I support both", so the browser uses the user's preference. The test compares it with matchMedia; on the checking machine (set to dark) it was dark.
  4. .normal gets the light values: normal means the element hasn't opted into any scheme, which renders as light. This is the classic "light-dark() doesn't work" bug: you forgot color-scheme: light dark on :root.
  5. The last box uses the system colours Canvas and CanvasText instead of your own. In a dark scheme Chrome resolves them to rgb(18, 18, 18) and rgb(255, 255, 255). They're what the browser uses for the default page, and they're why setting color-scheme alone already gives a usable dark page.
AdvancedA toggle that remembers the choice

A theme toggle in JavaScript

A toggle writes the choice to data-theme on <html> and remembers it. The middle line is exactly what the main demo's test runs (document.documentElement.dataset.theme = "dark"); storage is wrapped in try because it can be blocked:

JavaScript
function setTheme(theme) { // "light" | "dark"
  document.documentElement.dataset.theme = theme;
  try { localStorage.setItem("theme", theme); } catch {}
}

// In an inline <script> in <head>, before the CSS paints anything:
try {
  const saved = localStorage.getItem("theme");
  if (saved) document.documentElement.dataset.theme = saved;
} catch {}
What’s happening
  1. setTheme sets one attribute; the [data-theme="dark"] rule redefines the tokens and color-scheme, and the whole page follows (proven in the main demo: the inheriting panel went from rgb(255, 255, 255) to rgb(17, 24, 39)).
  2. The saved choice is restored by a tiny inline script in <head>, which runs before the first paint. If you restore it from a deferred script or after a framework boots, the page first paints in the default theme and then flips: the "flash of the wrong theme".
  3. With nothing saved, no attribute is set, and the prefers-color-scheme rules decide.
  4. Also add <meta name="color-scheme" content="light dark"> in <head>: it tells the browser before any CSS has loaded that the page supports dark, so even the blank page before your stylesheet arrives isn't a white flash for dark-mode users.

#Native CSS nesting

Added

Native nesting lets you write rules inside other rules, as you could in Sass or Less, but in plain CSS that the browser understands directly. A nested rule's selector is relative to its parent; & stands for the parent selector, and when a nested selector doesn't contain &, the browser puts & (the parent plus a descendant combinator) in front of it for you.

Mental model: nesting is shorthand, not a new kind of selector. .card { & .title {} } is exactly .card .title {}. What's different from Sass is how & is defined: it behaves like :is(<parent selector>), which affects specificity, and it's a selector, not a piece of text, so you can't glue strings onto it.

Status: nesting is Baseline 2023 (newly available since December 2023, when the relaxed syntax shipped in Chrome 120 and Safari 17.2) and widely available since June 2026. Older browsers drop nested rules they can't parse, so for an audience on old Safari you'd still compile it away (PostCSS, Sass, Lightning CSS).

A component written with nesting
CSS
.card {
  width: 220px;
  margin-bottom: 8px;
  padding: 10px;
  border: 2px solid #cbd5e1;
  color: #334155;

  & .title {
    margin: 0;
    color: #1d4ed8;
  }
  .meta {
    color: #64748b;
    font-size: 13px;
  }
  &.featured {
    border-color: #f59e0b;
  }
  &:hover {
    border-color: #2563eb;
  }
  .theme-dark & {
    background: #1e293b;
    color: #e2e8f0;
  }
  @media (min-width: 600px) {
    padding: 16px;
  }
}
HTML
<div class="card featured">
  <h3 class="title">Featured</h3>
  <span class="meta">nested rules</span>
</div>
<div class="theme-dark">
  <div class="card"><h3 class="title">Dark</h3></div>
</div>
Resultlive, in your browser
What’s happening
  1. & .title means "a .title inside a .card": the title is rgb(29, 78, 216). It's the same rule as writing .card .title at the top level.
  2. .meta has no &. The browser treats it as & .meta (the stylesheet's own record shows "& .meta"), so it's a descendant again: rgb(100, 116, 139).
  3. &.featured and &:hover have no space after &, so they qualify the card itself: .card.featured gets the amber border rgb(245, 158, 11), and .card:hover turns it blue (hover the live result; a test can't hover, but the rule is there as &:hover).
  4. .theme-dark & puts the parent at the end: "a .card inside .theme-dark". Only the second card matches: background rgb(30, 41, 59), text rgb(226, 232, 240). Context-dependent styles can live with the component instead of in a separate block.
  5. The nested @media applies its declarations to .card itself when it matches. The test runs at two widths: at 640 px the padding is 16px, at 360 px it stays 10px.
AdvancedSpecificity: & behaves like :is()

& behaves like :is(), and that changes specificity

Because & is :is(<parent list>), a nested rule under a selector list takes the highest specificity in that list, for every element it matches. Sass would expand .list, #main { .item {} } into two separate rules (.list .item and #main .item) with their own specificities; native CSS doesn't.

The id in the parent list wins
CSS
.list, #main {
  & .item { color: rgb(22, 101, 52); }
}
.sidebar .item { color: rgb(185, 28, 28); }
HTML
<div class="sidebar">
  <ul class="list"><li class="item">Which colour?</li></ul>
</div>
Resultlive, in your browser
What’s happening
  1. The nested rule is :is(.list, #main) .item. :is() takes the specificity of its most specific argument, #main, so the rule's specificity is (1, 1, 0) — one id, one class — even though this element matched through .list, not #main.
  2. .sidebar .item is (0, 2, 0). It comes later, but order only breaks ties, and (1, 1, 0) beats (0, 2, 0).
  3. The item is green, rgb(22, 101, 52). In Sass the compiled .list .item would have been (0, 2, 0) and lost to the later red rule. Same-looking source, different winner: keep ids and mixed lists out of nesting parents. More on how :is() counts in :is(), :where(), :not() and :has().
AdvancedNot Sass: no string concatenation with &

& is a selector, not a string

In Sass, &__title inside .card builds the class name .card__title (the BEM habit). In CSS, & is a whole selector; &__title isn't a valid way to continue it, and the browser drops the rule.

CSS
.card {
  color: rgb(51, 65, 85);
  &__title { color: rgb(220, 38, 38); }
}
HTML
<div class="card"><span class="card__title">Am I red?</span></div>
Resultlive, in your browser
What’s happening
  1. The parent rule is kept, so the span inherits rgb(51, 65, 85) from .card.
  2. The nested &__title rule doesn't appear in the stylesheet at all (cssRules.length is 0): the parser rejected it. It never matched anything.
  3. If your codebase uses BEM with Sass, that part of the nesting can't move to native CSS; write .card__title as its own top-level rule, or keep the preprocessor for it.
AdvancedDeclarations after nested rules stay in order

Mixing declarations and nested rules

Declarations that come after a nested rule are applied in their written position. Since late 2024 the browser wraps them in a CSSNestedDeclarations rule so the order you see is the order that cascades (Chrome 130, Firefox 132, Safari 18.2; Baseline since December 2024). The first implementations hoisted all of a rule's declarations above its nested rules, which gave the opposite result in cases like this one.

CSS
.order {
  & { color: rgb(220, 38, 38); }
  color: rgb(22, 101, 52);
}
HTML
<p class="order">Red or green?</p>
Resultlive, in your browser
What’s happening
  1. & { color: red } is a nested rule matching .order itself, with the same specificity as the parent.
  2. color: green comes after it. The browser stores it as a CSSNestedDeclarations rule after the nested & rule, so in cascade order it's later and wins: rgb(22, 101, 52).
  3. In browsers before the change, the green declaration would have been moved to the top of .order, ahead of the nested rule, and the text would have been red. To be safe in code that has to run on older browsers, put a rule's own declarations first and nested rules after them, which is also the easiest to read.
AdvancedPractical rules for nesting
  • Nest one or two levels. Deep nesting writes long, high-specificity selectors you didn't intend (.page .main .card .title a), which is the same trap Sass users fell into.
  • Nested selectors can start with an element name (p { … } inside .card means .card p). The very first implementations (Chrome 112 to 119) required & p; the relaxed syntax shipped in Chrome 120 and Safari 17.2, the versions Baseline counts from.
  • Nesting doesn't change the cascade or add scoping: the output is still global selectors. For styles that should stop at a boundary, look at Scoping styles with @scope.
  • @media, @supports, @container and @layer can be nested inside a style rule, and their declarations apply to the parent selector:
CSS
.wrap { container-type: inline-size; width: 300px; }
.item {
  @layer base { color: rgb(30, 64, 175); }
  @container (min-width: 200px) { background: rgb(254, 243, 199); }
  @supports (display: grid) { border-left: 4px solid rgb(245, 158, 11); }
}
HTML
<div class="wrap"><p class="item">nested at-rules</p></div>
Resultlive, in your browser

#Logical properties

Added

Physical properties (margin-left, padding-top, border-right, left) describe the screen. Logical properties describe the text: the inline axis is the direction text runs in a line, and the block axis is the direction lines stack. Each axis has a start and an end. In English, inline-start is left and block-start is top; in Arabic or Hebrew (dir="rtl") inline-start is the right; in vertical Japanese (writing-mode: vertical-rl) the inline axis is vertical.

Mental model: physical properties say "left"; logical properties say "where the line begins". Write a component once with margin-inline-start, padding-block, border-inline-start and inset-inline-end, and it mirrors itself correctly in a right-to-left page with no extra CSS.

The mapping you need to remember: width ↔ inline-size, height ↔ block-size, margin-left ↔ margin-inline-start (in LTR), top ↔ inset-block-start, and two-value shorthands like margin-inline: 0 auto (start, end) and padding-block: 8px. Logical properties are Baseline (widely available since 2024).

The same CSS, left-to-right and right-to-left
CSS
.note {
  position: relative;
  width: 260px;
  margin-block: 0 8px;
  margin-inline-start: 30px;
  padding-block: 6px;
  padding-inline: 12px 40px;
  border-inline-start: 4px solid #2563eb;
  background: #eff6ff;
  text-align: start;
}
.note .tag {
  position: absolute;
  inset-block-start: 0;
  inset-inline-end: 0;
  width: 28px;
  height: 20px;
  background: #fbbf24;
}
HTML
<div class="note" id="ltr">Left-to-right note<span class="tag"></span></div>
<div class="note" id="rtl" dir="rtl">ملاحظة من اليمين<span class="tag"></span></div>
Resultlive, in your browser
What’s happening
  1. Both boxes share every declaration; the only difference is dir="rtl" on the second, which flips the inline direction.
  2. In LTR, margin-inline-start: 30px becomes margin-left: 30px, the border-inline-start is the 4 px border on the left, and padding-inline: 12px 40px is 12px left, 40px right.
  3. In RTL every one of those swaps sides: the margin is on the right (margin-right: 30px), the border is the right border, and the 40 px padding is on the left. The box itself sits at x = 12 instead of 42, because its start margin is now on the right.
  4. Block-axis values don't care about direction: padding-top is 6px in both and margin-block: 0 8px gives margin-bottom: 8px.
  5. The yellow tag uses inset-inline-end: 0: it sits flush with the box's right edge in LTR and its left edge in RTL (both measured offsets 0). With right: 0 you'd need a second rule for RTL pages.
AdvancedVertical writing modes swap the axes

When the inline axis is vertical

In writing-mode: vertical-rl, lines run top to bottom and stack right to left. inline-size is then the height and block-size the width.

CSS
.vertical {
  writing-mode: vertical-rl;
  height: 160px;
}
.vertical .bar {
  inline-size: 100px;
  block-size: 40px;
  margin-inline-start: 10px;
  background: #c7d2fe;
}
HTML
<div class="vertical"><div class="bar">縦書き</div></div>
Resultlive, in your browser
What’s happening
  1. inline-size: 100px sizes the axis text runs along, which is vertical here, so the bar is 100 px tall. block-size: 40px gives it 40 px of width.
  2. margin-inline-start: 10px becomes a top margin (margin-top: 10px), because vertical lines start at the top.
  3. This is why design systems that support East Asian layouts use logical properties everywhere: the same component works in horizontal and vertical text.
AdvancedGotcha: inset and the physical shorthands

#Math functions: calc(), min(), max() and clamp()

Added

CSS has real arithmetic. calc() combines values with +, -, * and /, including mixed units like 100% - 2rem, which a preprocessor can't do because it doesn't know what 100% will be. min() and max() pick the smallest or largest of a list, and clamp(MIN, PREFERRED, MAX) keeps a preferred value between two bounds. All of them are resolved by the browser at layout time, against the real container, font size and viewport.

Mental model: they're formulas evaluated per element, every time layout runs, so a value like min(100%, 600px) behaves differently in a narrow and a wide container without any media query. The naming trips people up: min() produces an upper limit (it can never be more than the smallest argument), and max() a lower limit.

Status: calc() has been Baseline since 2015, min(), max() and clamp() since 2020. Stepped-value functions are newer (below).

The same four formulas in three container widths
CSS
.wrap {
  margin-bottom: 8px;
  outline: 1px dashed #94a3b8;
}
.w160 { width: 160px; }
.w400 { width: 400px; }
.w600 { width: 600px; }
.bar {
  height: 16px;
  margin: 2px 0;
  font: 11px/16px monospace;
  white-space: nowrap;
}
.calc { width: calc(100% - 2 * 20px); background: #bfdbfe; }
.min { width: min(100%, 300px); background: #bbf7d0; }
.max { width: max(50%, 180px); background: #fde68a; }
.clamp { width: clamp(150px, 50%, 250px); background: #fbcfe8; }
HTML
<div class="wrap w160">
  <div class="bar calc">calc</div><div class="bar min">min</div><div class="bar max">max</div><div class="bar clamp">clamp</div>
</div>
<div class="wrap w400">
  <div class="bar calc">calc</div><div class="bar min">min</div><div class="bar max">max</div><div class="bar clamp">clamp</div>
</div>
<div class="wrap w600">
  <div class="bar calc">calc</div><div class="bar min">min</div><div class="bar max">max</div><div class="bar clamp">clamp</div>
</div>
Resultlive, in your browser
What’s happening
  1. calc(100% - 2 * 20px) subtracts 40 px from whatever the container is: 120, 360 and 560. * and / are evaluated before + and -, as in ordinary maths.
  2. min(100%, 300px) is the smaller of the two: the full 160 in the narrow box, then capped at 300 in both wider ones. It's the one-line version of width: 100%; max-width: 300px.
  3. max(50%, 180px) is the larger: in the 160 px box 50% is only 80, so it uses 180 and overflows the box (on purpose here, to show that max() sets a floor even when there isn't room). Then 200 (50% of 400) and 300 (50% of 600).
  4. clamp(150px, 50%, 250px) takes the preferred 50% but keeps it between the bounds: 80 is raised to 150, 200 is used as is, and 300 is lowered to 250. It's exactly max(150px, min(50%, 250px)).
  5. None of these needed a media query: each bar responded to its own container. This is what makes fluid layouts and Fluid typography with clamp() work.
Advancedcalc() syntax rules and gotchas

Spaces matter in calc()

+ and - must have whitespace on both sides, because - is also part of numbers and identifiers (-10px, --var). * and / don't need spaces. An invalid calc() makes the whole declaration invalid, so the browser keeps whatever earlier declaration it had.

CSS
.b { height: 14px; margin: 2px 0; background: #c7d2fe; width: 40px; }
.nospace { width: 40px; width: calc(100%-10px); }
.spaced { width: calc(100% - 10px); }
.div { width: calc(100%/4); }
.nested { width: calc(50% - calc(10px * 2)); }
.typed { width: calc(100px * (20px / 10px)); }
HTML
<div style="width: 200px">
  <div class="b nospace"></div>
  <div class="b spaced"></div>
  <div class="b div"></div>
  <div class="b nested"></div>
  <div class="b typed"></div>
</div>
Resultlive, in your browser
What’s happening
  1. calc(100%-10px) is parsed as 100% followed by the number-like token -10px with no operator between them, which is a syntax error. The declaration is dropped and the box keeps 40px (CSS.supports reports false for it).
  2. With spaces, calc(100% - 10px) in a 200 px parent is 190px.
  3. calc(100%/4) is fine without spaces: 50px. Only + and - are fussy.
  4. calc() nests (calc(50% - calc(10px * 2)) is 80px); the inner calc is optional, plain parentheses work too.
  5. Dividing a length by a length used to be invalid (the divisor had to be a plain number). Chrome 155 accepts it: calc(100px * (20px / 10px)) gives 200px. The web-features data that MDN's Baseline badges come from had no entry for this in October 2026, so don't rely on it outside Chrome yet.
AdvancedStepped values: round(), mod(), rem(), abs()

round(), mod(), rem() and friends

Newer math functions snap values to steps or work with signs: round(<strategy>, value, step) with nearest, up, down or to-zero; mod() and rem() for remainders (they differ for negative numbers, like in maths vs JavaScript); abs() and sign(). round(), mod() and rem() are Baseline 2024 (May); abs() and sign() Baseline 2025 (June, when Chrome 138 added them). The trigonometric functions (sin(), cos(), atan2()…) are Baseline 2023 and widely available since September 2025.

CSS
.s { height: 10px; margin: 2px 0; background: #fed7aa; }
.r1 { width: round(up, 37px, 10px); }
.r2 { width: round(down, 37px, 10px); }
.r3 { width: round(37px, 10px); }
.m1 { margin-left: mod(-7px, 5px); }
.m2 { margin-left: rem(-7px, 5px); }
.a1 { width: calc(abs(-30px) * 2); }
HTML
<div class="s r1"></div><div class="s r2"></div><div class="s r3"></div>
<div class="s m1"></div><div class="s m2"></div><div class="s a1"></div>
Resultlive, in your browser
What’s happening
  1. round(up, 37px, 10px) snaps up to the next multiple of 10: 40px; down gives 30px; the default strategy, nearest, gives 40px (37 is closer to 40).
  2. mod(-7px, 5px) is 3px: the result takes the sign of the divisor, like the mathematical modulo. rem(-7px, 5px) is -2px: it takes the sign of the dividend, like JavaScript's % (-7 % 5 === -2).
  3. abs(-30px) is 30px, doubled to 60px. These are handy with custom properties whose sign you don't control, and with sibling-index() for staggered effects.
clamp() in a narrow container

.title { width: clamp(200px, 50%, 300px); } What width does .title get inside a 300 px wide container, and inside an 800 px one?

Show answer

200 px in the 300 px container and 300 px in the 800 px one.

CSS
.title { width: clamp(200px, 50%, 300px); height: 16px; background: #a7f3d0; }
HTML
<div style="width: 300px"><div class="title"></div></div>
<div style="width: 800px"><div class="title"></div></div>
Resultlive, in your browser
What’s happening
  1. In the 300 px container the preferred value 50% is 150px. That's below the minimum, so clamp() returns the minimum, 200px. Measured: 200.
  2. In the 800 px container 50% is 400px. That's above the maximum, so it returns 300px. Measured: 300.
  3. Only a container between 400 and 600 px wide would use the preferred value itself. A common slip is to read the middle argument as "the value" and forget that the bounds win whenever it's outside them.
  4. If the minimum is larger than the maximum, the minimum wins: clamp(MIN, VAL, MAX) is defined as max(MIN, min(VAL, MAX)).

#Modern colour: oklch() and color-mix()

Added

rgb(), hex and hsl() all describe colours in the sRGB colour space, and HSL's "lightness" isn't how people see it: hsl(60 100% 50%) (yellow) and hsl(240 100% 50%) (blue) have the same L but the yellow looks far brighter. OKLCH is a perceptual colour space: oklch(L C H) takes a lightness from 0 to 1 (or 0% to 100%) that matches perceived brightness, a chroma (colourfulness, 0 to about 0.4) and a hue angle. Two colours with the same L look equally light, so you can build a palette by changing only the hue or the lightness and it stays consistent. OKLCH can also describe colours outside sRGB (the wider P3 gamut of modern screens); on an sRGB screen the browser maps them to the nearest colour it can show.

color-mix(in <space>, colour1 p%, colour2) mixes two colours in a colour space you choose. With white, black or transparent as the second colour it's the native way to make tints, shades and translucent versions of a brand colour, which used to need Sass's lighten() and rgba() tricks.

Mental model: hex and RGB are instructions for the screen's three lights; OKLCH is a description for human eyes. Status: oklch()/oklab() and color-mix() are Baseline 2023 (May) and widely available since November 2025.

A palette from one brand colour
CSS
:root { --brand: oklch(55% 0.2 262); }
.sw {
  display: inline-block;
  width: 110px;
  height: 46px;
  margin: 0 6px 6px 0;
  padding: 4px;
  font: 11px/1.3 monospace;
  color: #fff;
  vertical-align: top;
}
.base { background: var(--brand); }
.tint { background: color-mix(in oklch, var(--brand), white 40%); color: #111; }
.shade { background: color-mix(in oklch, var(--brand), black 30%); }
.glass { background: color-mix(in oklch, var(--brand), transparent 70%); color: #111; }
.fallback { background: #2563eb; background: oklch(55% 0.2 262); }
HTML
<div class="sw base">brand</div>
<div class="sw tint">+40% white</div>
<div class="sw shade">+30% black</div>
<div class="sw glass">70% transparent</div>
<div class="sw fallback">with a hex fallback</div>
Resultlive, in your browser
What’s happening
  1. --brand is oklch(55% 0.2 262): lightness 0.55, chroma 0.2, hue 262° (a strong blue). Computed, it stays in OKLCH: oklch(0.55 0.2 262).
  2. The tint mixes 60% brand with 40% white in OKLCH. White is lightness 1 (Chrome's conversion gives a hair under it) and chroma 0, and an achromatic colour's hue is powerless, so it doesn't pull the hue anywhere. Lightness goes 0.55 → 0.73 (0.6 × 0.55 + 0.4 × 1), chroma 0.2 → 0.12, and the hue stays the brand's 262. Measured: oklch(0.729998 0.12002 262), a lighter blue of the same hue (the last digits are floating-point rounding from converting white).
  3. The shade mixes 30% black (oklch(0 0 none)): lightness 0.385, chroma 0.14, hue unchanged. A darker version of the same blue.
  4. Mixing with transparent changes only the alpha: oklch(0.55 0.2 262 / 0.3). This replaces the old trick of keeping a separate --brand-rgb just to write rgba(var(--brand-rgb), 0.3).
  5. .fallback declares a hex colour first and the OKLCH one second. A browser that can't parse oklch() drops the second declaration and keeps the hex; a modern one uses the OKLCH value (measured: oklch(0.55 0.2 262)). That's all the fallback older browsers need.
AdvancedThe colour space changes the mix

Why "in oklch" matters

The same two colours mixed in different spaces give different results. Mixing in sRGB averages the red, green and blue channels, which is why blue plus yellow in sRGB gives grey: the channels cancel out. Mixing in OKLCH interpolates lightness, chroma and hue, so it travels around the colour wheel instead.

Blue and yellow, mixed three ways
CSS
.m { display: inline-block; width: 120px; height: 40px; margin-right: 6px; font: 11px monospace; padding: 4px; }
.srgb { background: color-mix(in srgb, blue, yellow); }
.oklab { background: color-mix(in oklab, blue, yellow); }
.oklch { background: color-mix(in oklch, blue, yellow); }
HTML
<div class="m srgb">in srgb</div><div class="m oklab">in oklab</div><div class="m oklch">in oklch</div>
Resultlive, in your browser
What’s happening
  1. in srgb: blue is (0, 0, 1) and yellow (1, 1, 0); the average of each channel is 0.5, so the result is color(srgb 0.5 0.5 0.5), a flat grey. Note the serialisation: a color-mix() in sRGB computes to color(srgb …), not rgb().
  2. in oklab: OKLab is the same perceptual space as OKLCH in rectangular coordinates (a green–red axis a and a blue–yellow axis b). The midpoint has lightness 0.709982, the average of the two, and a/b of -0.0518911/-0.0564726, a chroma of about 0.08: a light, muted blue-green, because the straight line between blue and yellow passes close to grey.
  3. in oklch: lightness is averaged the same way (0.709982), but hue is interpolated around the circle, the shorter way by default, from blue's 264.051° to yellow's 109.756° (both measured), and chroma stays high, so the result is a vivid teal-green, oklch(0.709982 0.262103 186.903). in oklch longer hue would go the long way round, through red (hue 6.90344).
  4. Which to use: oklch keeps colours vivid and hues predictable, which suits palettes; oklab avoids hue swings and suits mixing with neutrals; srgb matches what older tools did. If you leave out the in … part, Chrome 155 mixes in oklab (the test's color-mix(blue, yellow) gave exactly the oklab result); older browsers require the colour space, so write it.
AdvancedRelative colour syntax

Deriving colours with from

Relative colour syntax takes an existing colour apart into channels you can do maths on: oklch(from var(--brand) calc(l - 0.2) c h) means "the brand colour, 0.2 darker". It's Baseline 2024 (September).

CSS
:root { --brand: #2563eb; }
.r { display: inline-block; width: 140px; height: 40px; margin-right: 6px; padding: 4px; font: 11px monospace; color: #fff; }
.darker { background: oklch(from var(--brand) calc(l - 0.2) c h); }
.half { background: rgb(from var(--brand) r g b / 50%); color: #111; }
.hue { background: oklch(from var(--brand) l c calc(h + 180)); color: #111; }
.wide { background: oklch(70% 0.4 150); color: #111; }
HTML
<div class="r darker">l - 0.2</div><div class="r half">alpha 50%</div><div class="r hue">opposite hue</div><div class="r wide">out of sRGB</div>
Resultlive, in your browser
What’s happening
  1. oklch(from #2563eb …) first converts the hex colour to OKLCH, giving channels l = 0.54613, c = 0.215194, h = 262.885. calc(l - 0.2) gives 0.34613; chroma and hue pass through. Result: oklch(0.34613 0.215194 262.885).
  2. rgb(from #2563eb r g b / 50%) keeps the channels and sets alpha to 0.5. It computes to color(srgb 0.145098 0.388235 0.921569 / 0.5): 37/255, 99/255 and 235/255 as fractions.
  3. calc(h + 180) gives the complementary hue: 82.8851 (262.885… + 180 − 360), a yellow of the same lightness and chroma.
  4. oklch(70% 0.4 150) asks for a green more saturated than sRGB can show. The computed value keeps 0.4; gamut mapping to what the screen can display happens when it's painted, not in getComputedStyle. On a P3 screen you see more of it than on an sRGB one.

#Typography that behaves: text-wrap balance and pretty

Added

By default the browser breaks lines greedily: it fills each line as full as it can and moves on, never looking back. That's fast, but it gives headings with one long line and one short one, and paragraphs whose last line is a single word (an "orphan"). text-wrap: balance makes the browser pick breaks so all lines are about the same length, which is what you want for headings, captions and short blocks. text-wrap: pretty lets it consider the whole paragraph to avoid bad endings like orphans, which suits body text.

Mental model: greedy wrapping is packing a suitcase in order without looking ahead; balance repacks so every bag weighs the same; pretty only rearranges the last few items so nothing is left alone. Neither changes the font size or the box, only where lines break.

Status (MDN data, October 2026): text-wrap: balance is Baseline 2024 (May). text-wrap: pretty is not Baseline: Chrome 117+ and Safari 26 support it, Firefox doesn't. Both are safe to add anywhere, because a browser that doesn't support them just wraps normally.

The same heading, greedy and balanced
CSS
.h {
  width: 260px;
  margin: 0 0 12px;
  font-size: 20px;
  line-height: 30px;
  outline: 1px dashed #94a3b8;
}
.balanced { text-wrap: balance; }
HTML
<h2 class="h greedy">Modern CSS has quietly fixed most of the layout hacks we used</h2>
<h2 class="h balanced">Modern CSS has quietly fixed most of the layout hacks we used</h2>
Resultlive, in your browser
What’s happening
  1. Both headings are the same width (260 px) and font; the second adds text-wrap: balance.
  2. Greedy wrapping filled lines in order. On the Mac that checked this page the lines were "Modern CSS has quietly" / "fixed most of the layout" / "hacks we used": two full lines and a short last one.
  3. Balanced wrapping still uses 3 lines and the box is the same 90 px tall (3 × 30 px line height), but the breaks moved: "Modern CSS has quietly" / "fixed most of the" / "layout hacks we used". The test confirms the spread between longest and shortest line got smaller and the last line got longer. (Your browser's font may break differently; the live result shows what yours does.)
  4. Balancing only changes where lines break, not how many there are or how wide the box is. The box is still 260 px; the text inside it just isn't ragged.
  5. text-wrap is a shorthand: balance sets text-wrap-mode: wrap and text-wrap-style: balance. You can set the style longhand alone when another rule controls wrapping.
Advancedtext-wrap: pretty for paragraphs

Avoiding orphans with pretty

The same paragraph, greedy and pretty
CSS
.p {
  width: 258px;
  margin: 0 0 12px;
  font-size: 20px;
  line-height: 30px;
  outline: 1px dashed #94a3b8;
}
.pretty { text-wrap: pretty; }
HTML
<p class="p auto">The browser picks line breaks one line at a time, greedily, so the last line can end up holding a single lonely word.</p>
<p class="p pretty">The browser picks line breaks one line at a time, greedily, so the last line can end up holding a single lonely word.</p>
Resultlive, in your browser
What’s happening
  1. Greedy wrapping at 258 px left a single word, "word.", on the fifth line (measured in Chrome 155 on the checking Mac).
  2. With text-wrap: pretty, Chrome looked at the end of the paragraph, broke the fourth line one word earlier, and the last line became "lonely word.". Still 5 lines, same height.
  3. Unlike balance, pretty doesn't try to make all lines equal; it keeps normal ragged text and fixes the ugly cases. The spec leaves the details to each browser: Chrome's implementation only re-breaks the last four lines of a paragraph (per the Chrome team's shipping notes), while Safari's considers the whole paragraph, so results differ between them.
  4. In Firefox (no support as of October 2026) the second paragraph simply looks like the first. That's why it's safe to put p { text-wrap: pretty } in a base stylesheet.
AdvancedLimits and costs of balancing
  • Balancing is expensive (the browser tries several break positions), so engines cap it: per MDN, Chromium balances blocks of 6 lines or fewer and Firefox 10 or fewer. Longer text is wrapped normally. Use balance on headings, captions, card titles and quotes, not on articles.
  • pretty is also slower than greedy wrapping; MDN recommends it for text where the layout matters more than speed. On a page of normal length the cost is small, but don't put it on huge, constantly re-rendered text.
  • text-wrap: stable (Baseline 2024) keeps earlier lines from re-wrapping while the user edits later ones, which matters in contenteditable and text areas.
  • text-wrap: nowrap is the new way to say "don't wrap"; white-space is now a shorthand that sets text-wrap-mode along with how spaces collapse.

#Feature detection with @supports

Added

@supports applies a block of CSS only if the browser can parse a given declaration, selector or other feature. It's how you use a new feature as an enhancement while keeping a working fallback: write the simple version first, then override it inside @supports for browsers that understand the better one. The JavaScript twin is CSS.supports().

The forms you'll see: @supports (display: grid) (a property and value), not, and and or to combine conditions, selector(:has(a)) to test a selector (Baseline 2021), and font-tech() / font-format() to test font technologies (Baseline 2023).

Mental model: @supports asks the parser "would you accept this?" It doesn't ask whether the feature works well, which is the gotcha below. And often you don't need it at all, because the cascade already throws away declarations the browser can't parse.

Enhance only where supported
CSS
.layout { display: block; }
.layout > div { padding: 6px; margin-bottom: 4px; background: #e0e7ff; }
@supports (display: grid) {
  .layout { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
  .layout > div { margin-bottom: 0; }
}
@supports not (display: frobnicate) {
  .not { color: rgb(22, 101, 52); }
}
@supports selector(:has(img)) {
  .has { color: rgb(29, 78, 216); }
}
@supports (color: oklch(0 0 0)) and (text-wrap: balance) {
  .and { color: rgb(126, 34, 206); }
}
@supports (display: frobnicate) or (gap: 1px) {
  .or { color: rgb(190, 18, 60); }
}
HTML
<div class="layout" style="width: 300px"><div>one</div><div>two</div></div>
<p class="not">not (display: frobnicate)</p>
<p class="has">selector(:has(img))</p>
<p class="and">oklch and text-wrap: balance</p>
<p class="or">frobnicate or gap</p>
Resultlive, in your browser
What’s happening
  1. The fallback comes first and works everywhere: .layout is a block, its children stack with a margin between them.
  2. @supports (display: grid) is true in every current browser, so the grid version overrides it: display computes to grid, and the two children sit on the same row, side by side. A browser without grid would have skipped the whole block and kept the stacked version.
  3. not (display: frobnicate) is true because frobnicate isn't a valid display value, so .not is green.
  4. selector(:has(img)) asks whether the selector parses (true, so .has is blue). This is how you guard :has()-based styles, because an unsupported selector would invalidate the whole rule, not just one declaration.
  5. and needs both conditions (oklch colours and text-wrap: balance; both true, purple); or needs either (frobnicate is false but gap is true, rose).
  6. CSS.supports() answers the same questions from JavaScript, with either one string or a property and a value: true, false, true and, in Chrome, true for text-wrap: pretty.
AdvancedCSS.supports() from JavaScript

The JavaScript form, as run in the demo's test:

JavaScript
CSS.supports("display", "grid");           // true  (property, value)
CSS.supports("display: frobnicate");       // false (one condition string)
CSS.supports("selector(:has(img))");       // true
CSS.supports("text-wrap", "pretty");       // true in Chrome 155; false in Firefox
AdvancedThe fallback you get for free: the cascade

Invalid declarations are simply dropped

For a single property you don't need @supports. When the browser can't parse a declaration it ignores just that declaration, so the one before it stays in effect. Write the safe value first and the modern one after it.

CSS
.f1 { color: rgb(30, 64, 175); color: frob(1); }
.f2 { width: 200px; width: min(100%, 120px); height: 14px; background: #fecaca; }
HTML
<p class="f1">The unknown frob() is dropped</p>
<div class="f2"></div>
Resultlive, in your browser
What’s happening
  1. color: frob(1) isn't valid, so it's dropped at parse time and .f1 keeps rgb(30, 64, 175).
  2. In .f2 both declarations are valid in Chrome, so the later min(100%, 120px) wins: 120px. In a browser without min() the box would be 200 px wide.
  3. This "fallback first" pattern covers most cases (new colour functions, units, values). Reach for @supports when several declarations must change together (switching a whole layout) or when the fallback needs rules the modern version must undo.
  4. It doesn't work across var(): a declaration with var() is always accepted at parse time, so a bad substituted value can't fall back to the line above (see the quiz in Custom properties (CSS variables)).
AdvancedGotcha: what @supports can't tell you

#What's new in CSS since 2023

Added

CSS changed more between 2023 and 2026 than in the ten years before, largely because the browser makers agreed yearly priorities in the Interop project. Many things that needed JavaScript or a preprocessor are now native: selecting a parent, querying a container, nesting, scoping, positioning a popup next to its anchor, animating between DOM states.

To talk about support, use Baseline: a feature is Baseline newly available once it works in the latest Chrome, Edge, Firefox and Safari on desktop and mobile, and widely available 30 months after that. The statuses below come from the web-features data (version 3.43.0, published 15 October 2026), which is what MDN's Baseline badges show.

FeatureWhat it gives youStatus, October 2026On this site
:has()Select an element by what it contains (a "parent selector")Baseline 2023, widely available since June 2026:has()
Container size queriesStyle by the container's width, not the viewport'sBaseline 2023, widely since August 2025Container queries
Container style queries@container style(--theme: dark)Newly available May 2026 (Firefox 151)Container queries
NestingRules inside rules, without a preprocessorBaseline 2023, widely since June 2026Nesting
SubgridChildren align to the parent grid's tracksBaseline 2023, widely since March 2026Subgrid
@layerCascade layers to order whole groups of stylesBaseline 2022, widely since September 2024Cascade layers
@scopeStyles with an upper and lower boundaryNewly available March 2026 (Firefox 146)@scope
Anchor positioningPosition a popup relative to another element in CSSCore properties in all engines in 2026; the feature as a whole not yet BaselineAnchor positioning
View transitions (same-document)Animated transitions between DOM statesNewly available October 2025 (Firefox 144)View transitions
Cross-document view transitionsThe same between page loadsNot Baseline (Chrome 126, Safari 18.2; no Firefox)View transitions
@starting-style, transition-behaviorEntry animations; transitions on discrete propertiesBaseline 2024 (August)Entry animations
Popover APIpopover attribute: top-layer popups without JavaScriptBaseline 2025 (January)details, dialog and popover
field-sizing: contentInputs and text areas that grow with their contentNewly available June 2026 (Firefox 152)below
light-dark()One declaration for both colour schemesBaseline 2024 (May)Theming
oklch(), color-mix()Perceptual colour and native mixingBaseline 2023, widely since November 2025Modern colour
Relative colour syntaxoklch(from var(--c) l c h)Baseline 2024 (September)Modern colour
text-wrap: balance / prettyBetter line breaksbalance Baseline 2024; pretty not Baseline (no Firefox)text-wrap
@propertyTyped, animatable custom propertiesBaseline 2024 (July)Custom properties
round(), mod(), rem()Stepped valuesBaseline 2024 (May)Math functions
sibling-index(), sibling-count()An element's position among its siblings, as a numberNewly available August 2026 (Firefox 154)—
content-visibilitySkip rendering of off-screen contentBaseline 2025 (September)Reflow and repaint
svh, lvh, dvhViewport units that handle mobile toolbarsBaseline 2022, widely since June 2025Viewport units

Not Baseline yet in October 2026, but you'll hear about them: scroll-driven animations (animation-timeline; Chrome 115 and Safari 26, no Firefox), if() and @function (Chrome only), typed attr() (Chrome 133 and Firefox 155, no Safari), interpolate-size / calc-size() for animating to height: auto (Chrome only), customizable <select> (Chrome 135 and Safari 27), and text-box trimming (Chrome 133 and Safari 18.2).

The demo below uses four of these together: :has(), a container query, nesting and field-sizing.

A comment box built with 2023–2026 CSS
CSS
.box {
  container-type: inline-size;
  width: 340px;
  margin-bottom: 8px;
  padding: 8px;
  border: 2px solid #cbd5e1;

  &:has(textarea:focus) { border-color: #2563eb; }
  &:has(.avatar) { padding-inline-start: 52px; position: relative; }
  .avatar { position: absolute; inset-inline-start: 8px; inset-block-start: 8px; width: 36px; height: 36px; border-radius: 50%; background: #a5b4fc; }
  textarea { field-sizing: content; min-inline-size: 60px; max-inline-size: 100%; font: 14px/1.4 monospace; }
}
@container (min-width: 300px) {
  .actions { display: flex; gap: 6px; }
}
HTML
<div class="box" id="with">
  <span class="avatar"></span>
  <textarea>Hi</textarea>
  <div class="actions"><button>Post</button><button>Cancel</button></div>
</div>
<div class="box" id="without" style="width: 240px">
  <textarea>A longer comment that grows</textarea>
  <div class="actions"><button>Post</button><button>Cancel</button></div>
</div>
Resultlive, in your browser
What’s happening
  1. .box:has(.avatar) matches only the first box, because only it contains an avatar: its padding-inline-start is 52px, the second box keeps 8px. Before :has() you'd have added a modifier class like .box--with-avatar from JavaScript or a template.
  2. field-sizing: content makes each <textarea> as wide as its text instead of the fixed default size. The one with a long sentence is much wider than the one with "Hi", and min-inline-size: 60px stops the short one collapsing (watch the live result: typing in either one grows it).
  3. container-type: inline-size makes each box a query container. The @container (min-width: 300px) rule asks about that box's content width, not the viewport: the 340 px box lays its buttons out as flex, the 240 px box keeps block. Same component, two layouts, in the same page.
  4. &:has(textarea:focus) is nesting plus :has(): focusing the first textarea turns its box border rgb(37, 99, 235); blurring it returns to rgb(203, 213, 225). (The test focuses it from JavaScript; you can click into the live result.) :focus-within would do the same here, but :has() can also react to things like :checked or :invalid children.
  5. The point for interviews: each of these replaced a JavaScript workaround (class toggling, resize observers, auto-growing text areas) with one line of CSS. Check the table above for which ones need a fallback for your audience: in October 2026 field-sizing is only newly available, so older browsers show a normal fixed-size textarea, which is a fine fallback.
AdvancedHow to keep up and decide what to use
  • Check the Baseline badge at the top of each MDN page: "Widely available" needs no fallback for most audiences; "Newly available" works in current browsers but not in older versions still in use; "Limited availability" needs a fallback or @supports.
  • web.dev publishes a monthly "New to the web platform" post, and the Interop dashboard shows what the browser makers are working on this year.
  • Prefer features that fail gracefully: text-wrap: pretty, field-sizing and view transitions just do nothing where unsupported, so you can ship them early. Layout features (anchor positioning, subgrid) need a working fallback layout first.

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.