~/$CSS//2026Modern CSS · v1.0
01 · Foundation

CSS Grew Up

Between 2023 and 2026 CSS absorbed most of the jobs we used to hire JavaScript for: measuring elements, selecting parents, positioning tooltips, animating page transitions, reacting to scroll. Each of those was once a library, a resize listener, or an observer — now each is a declarative one-liner that runs off the main thread and degrades gracefully.

The workaround era is over. If your 2026 codebase still ships JS to do layout math, the fix is usually deletion.

What Shipped

📦
Container queries
Components respond to the space they are given, not the viewport. Truly reusable responsive components.
👪
:has()
The parent selector, finally. Style any element by what it contains — or by what its descendants are doing.
🪺
Nesting + @layer
Sass-style nesting natively, and explicit cascade layers that end specificity wars by decree.
🎬
View transitions
Animated state and page changes from two snapshots and a crossfade — same-document and cross-document.
⚓
Anchor positioning
Tooltips, menus, and popovers tethered in pure CSS, with automatic flip fallbacks when they would clip.
📜
Scroll-driven animation
Scroll position as an animation timeline. Progress bars, reveals, and parallax without an observer in sight.
The shift
Each feature moves work from imperative JS on the main thread to declarative CSS in the engine. That is not just less code — it is fewer hydration dependencies, fewer layout thrashes, and behavior that keeps working when the bundle fails to load.
01 · Foundation

Container Queries

Media queries answer the wrong question. A card does not care how wide the screen is — it cares how wide its column is. For twenty years components had no way to ask, so we shipped ResizeObservers and width props instead. Container queries let the component measure the box it actually lives in.

@media (width > 600px)ruler = viewportviewportcomponentcomponent cannot know its own space@container (width > 400px)viewportruler = containercomponentcomponent responds to the space it actually has
media queries measure the viewport · container queries measure the nearest containment ancestor

The Mechanics

A wrapper opts in with container-type: inline-size; descendants query it with @container and size themselves with container units like cqi. The queried element must be an ancestor — a container cannot query itself.

container-queries.css
/* 1. Name a containment context on the WRAPPER */ .card-wrap { container-type: inline-size; container-name: card; } /* 2. Style children by the container's width, not the viewport's */ @container card (width > 420px) { .card { display: flex; gap: 16px; } .card .thumb { width: 120px; } } /* 3. Container query units: cqi = 1% of container inline size */ .card h3 { font-size: clamp(1rem, 4cqi, 1.6rem); } /* The same card is now correct in a sidebar, a modal, and a full-width hero. No props, no resize listeners, no JS. */
live demo — drag the slider to resize the container
Product card
The viewport never changed — only the container did. The card re-lays itself out at its own 380px breakpoint.
Rule of thumb
Keep @media for page-level scaffolding — nav, columns, gutters. Everything inside a component that used to check the viewport should check its container instead. Design systems built this way stop needing size="compact" props.
01 · Foundation

:has() — the Parent Selector

The most-requested CSS feature in history: select an element based on its descendants. Twenty years of .parent-active class toggles, MutationObservers, and controlled React state existed only because CSS could not look down the tree. Now it can — and it re-evaluates live, automatically.

Form State Without JS

The classic case: a field wrapper that reacts to the validity of the input inside it, and a submit button that reacts to the whole form.

form-validation.css
/* Style the WRAPPER from the state of the input inside it */ .field:has(input:invalid:not(:placeholder-shown)) { border-color: var(--danger); } .field:has(input:invalid:not(:placeholder-shown)) .hint { visibility: visible; color: var(--danger); } /* Disable submit while ANY field is invalid - zero JS */ form:has(input:invalid) button[type=submit] { opacity: 0.5; pointer-events: none; }

Quantity & Sibling Awareness

Combined with :nth-child, :has() gives you layouts that respond to how many children they contain — and effects that reach sideways across siblings.

quantity-queries.css
/* Quantity-aware layout: the grid reacts to its own children */ .gallery { display: grid; grid-template-columns: repeat(3, 1fr); } .gallery:has(> :nth-child(5)) { grid-template-columns: repeat(4, 1fr); /* 5+ items: go denser */ } .gallery:has(> :only-child) { grid-template-columns: 1fr; /* one item: let it breathe */ } /* Sibling reactions: dim the cards you are NOT hovering */ .gallery:has(.card:hover) .card:not(:hover) { opacity: 0.55; }
live demo — toggle the checkbox, watch the parent
Performance note
:has() is heavily optimized, but a bare *:has(...) asks the engine to consider every element on the page. Anchor it to a class or element type, and keep the inner selector shallow. Scoped, it is effectively free.
02 · New Powers

Nesting & @layer

The two features that dissolved the CSS toolchain. Native nesting removed the last everyday reason to compile Sass; cascade layers removed the specificity arms race that made big stylesheets hostile. Together they make plain .css files viable for design-system-scale work again.

nesting-layers.css
/* Native nesting - no Sass, no build step */ .card { padding: 1rem; border: 1px solid gray; &:hover { border-color: hotpink; } .title { font-weight: 600; } /* descendant, & implied */ @media (width < 600px) { padding: 0.5rem; } } /* @layer: declare cascade order ONCE, argue about it never */ @layer reset, vendor, tokens, components, utilities; @layer components { .btn { background: navy; } } @layer utilities { /* later layer wins regardless of specificity - a one-class utility beats any component selector, by design */ .hidden { display: none; } }
How @layer changes the game
Order between layers is decided by the declaration order in the @layer statement — not by specificity, not by source order, not by !important escalation. Unlayered styles beat all layers, which gives app code a clean override point above vendor CSS by default.

A Sane Layer Stack

🧹
reset
normalize, box-sizing
📦
vendor
third-party CSS, quarantined
🎨
tokens
custom properties
🧩
components
the bulk of the code
🔧
utilities
always win, by decree
02 · New Powers

View Transitions

The browser takes a screenshot of the old state, you mutate the DOM however you like, it screenshots the new state, then it crossfades the two — and hands you CSS pseudo-elements to restyle every part of that animation. FLIP libraries, exit-animation hacks, and animated-route frameworks compressed into one API.

::view-transition-oldold snapshotopacity 1 → 0crossfade::view-transition-newnew snapshotopacity 0 → 1callstartViewTransition()snapshot oldDOM updates(hidden)snapshot newanimate →finished
anatomy of document.startViewTransition(): two screenshots and a crossfade you can restyle

Same-Document

One function call wraps any synchronous DOM update. Elements given a view-transition-name are tracked individually — matching names in the old and new state morph position and size, which is how thumbnail-to-hero effects fall out for free.

view-transition.ts
// Same-document view transition: wrap the DOM change, done. function showPanel(next: Panel) { if (!document.startViewTransition) { render(next); // graceful degradation return; } const transition = document.startViewTransition(() => { render(next); // any sync DOM update }); transition.finished.then(() => { console.log('old/new snapshots crossfaded'); }); }
view-transition.css
/* Give an element a stable identity across the two states */ .hero-img { view-transition-name: hero; } /* Customize the default crossfade per name */ ::view-transition-old(hero) { animation: fade-out 0.25s ease both; } ::view-transition-new(hero) { animation: slide-up 0.35s ease both; } /* Cross-document (MPA) transitions: one opt-in rule per page. Full page navigations now animate like an SPA. */ @view-transition { navigation: auto; }

Cross-Document

The same machinery works across full page loads. Two pages that both opt in via @view-transition get animated MPA navigation — no router, no framework, no client-side navigation shim.

Degradation is built in
Feature-detect document.startViewTransition and fall back to calling your render function directly. Unsupported browsers get an instant swap — the exact behavior you shipped before — which makes this one of the safest new APIs to adopt early.
02 · New Powers

Anchor Positioning

Positioning a tooltip next to a button used to mean a JS library: measure the trigger, measure the floating element, compute placement, listen for scroll and resize, flip on collision. Anchor positioning moves that entire job into the layout engine — declare the tether, declare the fallbacks, done.

anchor.css
/* The trigger declares an anchor name */ .save-btn { anchor-name: --save; } /* The tooltip attaches to it - no Popper.js, no getBoundingClientRect, no scroll listeners */ .tooltip { position: fixed; position-anchor: --save; position-area: block-start; /* centered above the anchor */ margin-block-end: 8px; /* auto-reposition when it would clip the viewport */ position-try-fallbacks: flip-block, flip-inline; } /* Pairs with the popover attribute for dismiss + top-layer: <button popovertarget=menu> ... <div popover id=menu> */
live demo — hover the button (needs anchor-positioning support)
anchored tooltip — zero positioning JS
The trio that kills the dropdown library
popover (top layer, light dismiss, focus handling) + anchor positioning (placement, flip fallbacks) + :has() (trigger-state styling). An accessible dropdown menu is now an HTML attribute and a dozen lines of CSS.
02 · New Powers

Scroll-Driven Animations

animation-timelineswaps an animation's clock: instead of seconds, progress is driven by how far something has scrolled — scroll() tracks a scroll container's position, view()tracks an element's journey through the viewport. Scrub backwards and the animation runs backwards, in perfect sync, off the main thread.

scroll-driven.css
/* Reading-progress bar: zero JS, runs off the main thread */ @keyframes grow { from { scale: 0 1; } to { scale: 1 1; } } .progress { transform-origin: left; animation: grow linear both; animation-timeline: scroll(root block); /* page scroll = playhead */ } /* Reveal-on-scroll, replacing IntersectionObserver boilerplate */ @keyframes reveal { from { opacity: 0; translate: 0 40px; } } .card { animation: reveal linear both; animation-timeline: view(); /* element visibility */ animation-range: entry 0% entry 80%; /* map to its journey */ }

What It Replaces

reading progress barsreveal-on-scrollparallax layersshrinking sticky headersimage scrub sequencesIntersectionObserver glue code
Respect the reader
Scroll-linked motion is exactly the category that makes vestibular-sensitive users ill. Wrap the flashy timelines in @media (prefers-reduced-motion: no-preference) so the default experience is the calm one.
03 · Architecture

Tokens, light-dark(), color-mix()

Custom properties stopped being variables and became the design system's runtime. With light-dark() a token carries both themes in one declaration, and color-mix() derives hover states, borders, and tints from a handful of base colors — in oklch, where math on colors finally looks right to human eyes.

tokens.css
:root { color-scheme: light dark; /* One declaration, both themes. No .dark class, no toggle JS, no flash of wrong theme before hydration. */ --surface: light-dark(#ffffff, #10131f); --ink: light-dark(#1a2233, #d7e6f2); --brand: oklch(0.72 0.19 195); /* Derive states instead of hardcoding sibling hexes */ --brand-hover: color-mix(in oklch, var(--brand), white 12%); --brand-border: color-mix(in oklch, var(--brand), transparent 60%); } .btn { background: var(--brand); border: 1px solid var(--brand-border); color: var(--surface); &:hover { background: var(--brand-hover); } }

Why This Beats the JS Theme Toggle

class-toggle theming
Every token defined twice
JS must run before first paint
Flash of wrong theme on load
Palette drift: 50 hardcoded hexes
light-dark() + color-mix()
One declaration, both themes
Resolved by the engine, pre-paint
Follows the OS preference free
Palette derived from ~6 base tokens
Still want a manual toggle?
Set color-scheme: light or dark on :root from a one-line script and every light-dark() token flips with it. The toggle becomes one property write instead of a class-swapping theme framework.
03 · Architecture

What Replaced What

The honest 2026 audit: most of the "essential" UI JavaScript of the 2010s now has a native CSS successor that is smaller, faster, and unbreakable by a failed bundle. Migrating is mostly deleting code.

the JS workaround era
Popper.js / Floating UI placement
IntersectionObserver reveal glue
ResizeObserver + width props
Theme-toggle JS + FOUC hacks
FLIP / route-transition libraries
Sass for nesting + variables
.parent-active class toggles
native css, 2026
anchor-name + position-area
animation-timeline: view()
@container + cqi units
color-scheme + light-dark()
document.startViewTransition
native nesting + custom properties
:has() on the parent, live
Check before you delete
The features on this page span baseline-stable (nesting, :has(), container queries, @layer) to newly-interoperable (anchor positioning, cross-document view transitions, scroll timelines). Gate the sharp edges with @supports, keep the boring fallback, and let the library be the fallback's fallback — then delete it when your support matrix allows.
04 · Execution

Modernization Checklist

Work top to bottom — the early items are baseline-safe everywhere and pay for themselves in deleted JavaScript; the later items want an @supports guard while support finishes landing.

modernization checklist — 0/11 complete
Replace resize listeners / matchMedia component logic with container queries
Convert parent-styling and state-mirroring JS to :has()
Delete the Sass build step where native nesting covers it
Declare @layer order once; put resets and vendor CSS in low layers
Ship theming with color-scheme + light-dark() instead of a JS class toggle
Derive hover / border / tint states with color-mix, not hardcoded hexes
Swap Popper / Floating UI tooltips for anchor positioning + position-try
Replace IntersectionObserver reveals with animation-timeline: view()
Add view-transition-name to elements that persist across navigations
Wrap SPA state changes in document.startViewTransition with a no-op fallback
Guard sharp edges with @supports and keep a boring fallback path
Where to start
If you adopt only three: container queries for any component that renders in more than one column width, :has() to delete your state-mirroring classes, and light-dark() tokens to kill the theme flash. All three are pure deletions from the JS bundle.
Modern CSS Reference
container queries · :has · view transitions
experiments.tyler.lab