Container queries, CSS layers, :has(), color-mix(), nesting, scroll-driven animations, and anchor positioning. The CSS features that made me stop reaching for JavaScript.
I deleted my last Sass file six months ago. Not because I was making a statement. Because I genuinely didn't need it anymore.
For over a decade, CSS was the language we apologized for. We needed preprocessors for nesting and variables. We needed JavaScript for container-based sizing, scroll-linked animations, parent selection, and half the layout patterns designers handed us. We built entire runtime systems — CSS-in-JS libraries, utility frameworks, PostCSS plugin chains — to compensate for what the language couldn't do natively.
That era is over. Not "almost over." Not "getting there." Over.
The features that landed in browsers between 2024 and 2026 didn't just add convenience. They changed the mental model. CSS is no longer a styling language you fight against. It's a styling language that actually thinks about components, specificity management, responsive design at the element level, and animation without a runtime.
Here's what changed, why it matters, and what you can stop doing because of it.
This is the one that fundamentally altered how I think about responsive design. For twenty years, we had media queries. Media queries ask: "how wide is the viewport?" Container queries ask: "how wide is the container this component lives in?"
That distinction sounds subtle. It's not. It's the difference between components that only work in one layout context and components that work everywhere.
Consider a card component. In a sidebar, it should stack vertically with a small image. In a main content area, it should go horizontal with a larger image. In a full-width hero section, it should be something else entirely.
With media queries, you'd write something like this:
/* The old way: coupling component styles to page layout */
.card {
display: flex;
flex-direction: column;
}
@media (min-width: 768px) {
.sidebar .card {
/* Still vertical in sidebar */
}
.main-content .card {
flex-direction: row;
/* Horizontal in main area */
}
}
@media (min-width: 1200px) {
.hero .card {
/* Yet another layout */
}
}The card knows about .sidebar, .main-content, and .hero. It knows about the page. It's not a component anymore — it's a page-aware fragment. Move it to a different page and everything breaks.
/* The container query way: component knows only about itself */
.card-wrapper {
container-type: inline-size;
container-name: card;
}
.card {
display: grid;
grid-template-columns: 1fr;
gap: 1rem;
}
@container card (min-width: 400px) {
.card {
grid-template-columns: 200px 1fr;
}
}
@container card (min-width: 700px) {
.card {
grid-template-columns: 300px 1fr;
gap: 2rem;
font-size: 1.125rem;
}
}The card doesn't know where it lives. It doesn't care. Put it in a 300px sidebar and it's vertical. Put it in a 700px main area and it's horizontal. Drop it in a full-width section and it adapts. Zero knowledge of the page layout required.
inline-size vs size#You'll almost always want container-type: inline-size. This enables queries on the inline axis (width in horizontal writing modes). Using container-type: size enables both inline and block axis queries, but it requires the container to have explicit sizing in both dimensions, which breaks normal document flow in most cases.
/* This is what you want 99% of the time */
.wrapper {
container-type: inline-size;
}
/* This requires explicit height — rarely what you want */
.wrapper-both {
container-type: size;
height: 500px; /* required, or it collapses */
}When you nest containers, naming becomes essential:
.page-layout {
container-type: inline-size;
container-name: layout;
}
.sidebar {
container-type: inline-size;
container-name: sidebar;
}
/* Target the sidebar specifically, not the nearest ancestor */
@container sidebar (max-width: 250px) {
.nav-item {
font-size: 0.875rem;
padding: 0.25rem;
}
}
/* Target the page layout */
@container layout (min-width: 1200px) {
.page-header {
font-size: 2.5rem;
}
}Without names, @container queries the nearest ancestor with containment. With nesting, that's often not the container you want. Name them. Always.
This one's underrated. Container query units (cqi, cqb, cqw, cqh) let you size things relative to the container, not the viewport:
@container (min-width: 400px) {
.card-title {
font-size: clamp(1rem, 4cqi, 2rem);
}
}4cqi is 4% of the container's inline size. The title scales with the container, not the window. This is what fluid typography should have been from the start.
If container queries changed how I think about responsive design, @layer changed how I think about CSS architecture. For the first time, we have a sane, declarative way to manage specificity across an entire project.
CSS specificity is a point system that doesn't care about your intentions. A utility class with .text-red loses to .card .title because the latter has higher specificity. The fix was always the same: make your selectors more specific, add !important, or restructure everything.
We built entire methodologies (BEM, SMACSS, ITCSS) and toolchains just to avoid specificity conflicts. All of that was a workaround for a missing language feature.
@layer lets you declare the order in which groups of styles are considered, regardless of specificity within those groups:
/* Declare layer order — this single line controls everything */
@layer reset, base, components, utilities, overrides;
@layer reset {
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
}
@layer base {
body {
font-family: system-ui, sans-serif;
line-height: 1.6;
color: oklch(20% 0 0);
}
h1, h2, h3 {
line-height: 1.2;
text-wrap: balance;
}
}
@layer components {
.card {
background: white;
border-radius: 0.5rem;
padding: 1.5rem;
box-shadow: 0 1px 3px oklch(0% 0 0 / 0.12);
}
.card .title {
font-size: 1.25rem;
font-weight: 600;
color: oklch(25% 0.05 260);
}
}
@layer utilities {
.text-red {
color: oklch(55% 0.25 25);
}
.hidden {
display: none;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
}
}Even though .card .title has higher specificity than .text-red, the utility wins because the utilities layer is declared after components. No !important. No specificity hacks. The layer order is the final word.
Tailwind v4 leans heavily on @layer. When you write @import "tailwindcss", you get:
@layer theme, base, components, utilities;Every Tailwind utility lives in the utilities layer. Your custom component styles go in components. This is why a text-red-500 class can override a component's color without needing !important — it's in a later layer.
If you're building your own design system without Tailwind, steal this architecture. It's the right one:
@layer reset, tokens, base, layouts, components, utilities, overrides;Seven layers is plenty. I've never needed more.
One gotcha: styles that aren't in any layer have the highest priority. This is actually useful — it means your one-off page-specific overrides automatically win:
@layer components {
.modal {
background: white;
}
}
/* Not in any layer — wins over everything in layers */
.special-page .modal {
background: oklch(95% 0.02 260);
}But it also means third-party CSS that isn't layer-aware can override your entire system. Wrap third-party styles in a layer to control them:
@layer third-party {
@import url("some-library.css");
}:has() Selector: The Parent Selector We Always Wanted#For literally decades, developers asked for a parent selector. "I want to style a parent based on its children." The answer was always "CSS can't do that" followed by a JavaScript workaround. :has() changes this completely, and it turns out it's even more powerful than what we asked for.
/* Style a form group when its input has focus */
.form-group:has(input:focus) {
border-color: oklch(55% 0.2 260);
box-shadow: 0 0 0 3px oklch(55% 0.2 260 / 0.15);
}
/* Style a card differently when it contains an image */
.card:has(img) {
padding-top: 0;
}
.card:has(img) .card-content {
padding: 1.5rem;
}
/* A card without an image gets different treatment */
.card:not(:has(img)) {
border-left: 4px solid oklch(55% 0.2 260);
}This is where :has() gets genuinely exciting. Combined with HTML validation pseudo-classes, you can build form UIs that respond to validity state with zero JavaScript:
/* The field wrapper reacts to its input's validity */
.field:has(input:invalid:not(:placeholder-shown)) {
--field-color: oklch(55% 0.25 25);
}
.field:has(input:valid:not(:placeholder-shown)) {
--field-color: oklch(55% 0.2 145);
}
.field {
--field-color: oklch(70% 0 0);
border: 2px solid var(--field-color);
border-radius: 0.5rem;
padding: 0.75rem;
transition: border-color 0.2s;
}
.field label {
color: var(--field-color);
font-size: 0.875rem;
font-weight: 500;
}
.field .error-message {
display: none;
color: oklch(55% 0.25 25);
font-size: 0.8rem;
margin-top: 0.25rem;
}
.field:has(input:invalid:not(:placeholder-shown)) .error-message {
display: block;
}The :not(:placeholder-shown) part is crucial — it prevents validation styles from appearing on empty fields that haven't been touched yet.
This pattern is absurdly useful and was genuinely impossible before :has():
/* Adjust grid columns based on how many items exist */
.grid-auto:has(> :nth-child(4)) {
grid-template-columns: repeat(2, 1fr);
}
.grid-auto:has(> :nth-child(7)) {
grid-template-columns: repeat(3, 1fr);
}
.grid-auto:has(> :nth-child(13)) {
grid-template-columns: repeat(4, 1fr);
}The grid changes its column count based on how many children it has. No JavaScript. No ResizeObserver. No class toggling.
One of my favorite patterns — changing the layout based on whether a sidebar exists:
.page-layout {
display: grid;
grid-template-columns: 1fr;
gap: 2rem;
}
/* If the layout contains a sidebar, switch to two columns */
.page-layout:has(.sidebar) {
grid-template-columns: 1fr 300px;
}
/* Adjust main content width when sidebar is present */
.page-layout:has(.sidebar) .main-content {
max-width: 65ch;
}Add a sidebar component to the DOM, the layout adjusts. Remove it, it adjusts back. The CSS is the source of truth, not a state variable.
:has() with Other Selectors#:has() composes beautifully with everything else:
/* Style an article only when it has a specific class of figure */
article:has(figure.full-bleed) {
overflow: visible;
}
/* A navigation that changes when it contains a search input */
nav:has(input[type="search"]:focus) {
background: oklch(98% 0 0);
box-shadow: 0 2px 8px oklch(0% 0 0 / 0.08);
}
/* Enable dark-mode at the component level */
.theme-switch:has(input:checked) ~ main {
color-scheme: dark;
background: oklch(15% 0 0);
color: oklch(90% 0 0);
}Browser support is excellent now. Every modern browser has supported :has() since early 2024. There's no reason not to use it.
I'm not going to oversell this one. CSS nesting is nice. It's not revolutionary the way container queries or :has() are. But it removes one of the last reasons to use Sass, and that matters.
.card {
background: white;
border-radius: 0.5rem;
padding: 1.5rem;
.title {
font-size: 1.25rem;
font-weight: 600;
margin-bottom: 0.5rem;
}
.description {
color: oklch(40% 0 0);
line-height: 1.7;
}
&:hover {
box-shadow: 0 4px 12px oklch(0% 0 0 / 0.1);
}
&.featured {
border: 2px solid oklch(60% 0.2 260);
}
}This is valid CSS. No build step. No preprocessor. No PostCSS plugin. The browser handles it natively.
There are a few syntax differences worth knowing:
/* CSS Nesting — works now in all browsers */
.parent {
/* Direct class/element nesting works without & */
.child {
color: red;
}
/* & is required for pseudo-classes and compound selectors */
&:hover {
background: blue;
}
&.active {
font-weight: bold;
}
/* Nested media queries — this is great */
@media (width >= 768px) {
flex-direction: row;
}
}In early implementations, you needed & before element selectors (like & p instead of just p). That restriction was relaxed. As of 2025, all major browsers support bare element nesting: .parent { p { ... } } works fine.
This is the killer feature of CSS nesting, in my opinion. Not the selector nesting — the ability to put media queries inside a rule block:
.hero {
padding: 2rem;
font-size: 1rem;
@media (width >= 768px) {
padding: 4rem;
font-size: 1.25rem;
}
@media (width >= 1200px) {
padding: 6rem;
font-size: 1.5rem;
}
}Compare this to the old way where your .hero styles were scattered across three different @media blocks, possibly hundreds of lines apart. Nesting keeps the responsive behavior co-located with the component. Readability improves dramatically.
One warning: just because you can nest six levels deep doesn't mean you should. The same advice from Sass applies here. If your nesting creates selectors like .page .section .card .content .text .highlight, you've created a specificity monster and a maintenance nightmare. Two or three levels is the sweet spot.
/* Good — two levels */
.nav {
.link {
color: inherit;
&:hover {
color: oklch(55% 0.2 260);
}
}
}
/* Bad — specificity nightmare */
.page {
.layout {
.sidebar {
.nav {
.list {
.item {
.link {
color: red; /* good luck overriding this */
}
}
}
}
}
}
}oklch() and color-mix() Change Everything#hsl() had a good run. It was more intuitive than rgb(). But it has a fundamental flaw: it's not perceptually uniform. An hsl(60, 100%, 50%) (yellow) looks dramatically lighter to the human eye than hsl(240, 100%, 50%) (blue), even though they have the same lightness value.
oklch() Wins#oklch() is perceptually uniform. Equal lightness values look equally light. This matters enormously when generating color palettes, creating themes, and ensuring accessible contrast:
:root {
/* oklch(lightness chroma hue) */
--color-primary: oklch(55% 0.2 260); /* blue */
--color-secondary: oklch(55% 0.2 330); /* purple */
--color-success: oklch(55% 0.2 145); /* green */
--color-danger: oklch(55% 0.25 25); /* red */
--color-warning: oklch(75% 0.18 85); /* yellow — higher L for equal perception */
/* These all look equally "medium" to the human eye */
/* With HSL, you'd need different lightness values for each hue */
}The three values are intuitive once you understand them:
color-mix() for Derived Colors#color-mix() lets you create colors from other colors at runtime. No Sass darken() function. No JavaScript. Just CSS:
:root {
--brand: oklch(55% 0.2 260);
/* Lighten by mixing with white */
--brand-light: color-mix(in oklch, var(--brand) 30%, white);
/* Darken by mixing with black */
--brand-dark: color-mix(in oklch, var(--brand) 70%, black);
/* Create a subtle background */
--brand-bg: color-mix(in oklch, var(--brand) 8%, white);
/* Semi-transparent version */
--brand-overlay: color-mix(in oklch, var(--brand) 50%, transparent);
}
.button {
background: var(--brand);
color: white;
&:hover {
background: var(--brand-dark);
}
&:active {
background: color-mix(in oklch, var(--brand) 60%, black);
}
}
.button.secondary {
background: var(--brand-bg);
color: var(--brand-dark);
&:hover {
background: color-mix(in oklch, var(--brand) 15%, white);
}
}The in oklch part matters. Mixing in srgb gives muddy intermediate colors. Mixing in oklch gives perceptually even results. Always mix in oklch.
oklch()#Here's how I generate an entire shade palette from a single hue:
:root {
--hue: 260;
--chroma: 0.2;
--color-50: oklch(97% calc(var(--chroma) * 0.1) var(--hue));
--color-100: oklch(93% calc(var(--chroma) * 0.2) var(--hue));
--color-200: oklch(85% calc(var(--chroma) * 0.4) var(--hue));
--color-300: oklch(75% calc(var(--chroma) * 0.6) var(--hue));
--color-400: oklch(65% calc(var(--chroma) * 0.8) var(--hue));
--color-500: oklch(55% var(--chroma) var(--hue));
--color-600: oklch(48% var(--chroma) var(--hue));
--color-700: oklch(40% calc(var(--chroma) * 0.9) var(--hue));
--color-800: oklch(32% calc(var(--chroma) * 0.8) var(--hue));
--color-900: oklch(25% calc(var(--chroma) * 0.7) var(--hue));
--color-950: oklch(18% calc(var(--chroma) * 0.5) var(--hue));
}Change --hue to 145 and you have a green palette. Change it to 25 and you have red. The lightness steps are perceptually even. The chroma tapers at the extremes so the lightest and darkest shades aren't oversaturated. This is the kind of thing that used to require a design tool or a Sass function. Now it's eight lines of CSS.
This is the feature that made me delete the most JavaScript. Scroll-linked animations — progress bars, parallax effects, reveal animations, sticky headers with transitions — used to require IntersectionObserver, scroll event listeners, or a library like GSAP. Now it's CSS.
The classic "reading progress bar" at the top of an article page:
.progress-bar {
position: fixed;
top: 0;
left: 0;
height: 3px;
background: oklch(55% 0.2 260);
transform-origin: left;
z-index: 1000;
animation: grow-progress linear both;
animation-timeline: scroll();
}
@keyframes grow-progress {
from {
transform: scaleX(0);
}
to {
transform: scaleX(1);
}
}That's it. The entire reading progress indicator. No JavaScript. No scroll event listener. No requestAnimationFrame. No "percentage scrolled" calculation. The animation-timeline: scroll() binding does everything.
Elements that fade in as they enter the viewport:
.reveal {
animation: reveal linear both;
animation-timeline: view();
animation-range: entry 0% entry 100%;
}
@keyframes reveal {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}animation-timeline: view() ties the animation to the element's visibility in the viewport. animation-range: entry 0% entry 100% means the animation runs from the moment the element starts entering the viewport until it's fully visible.
.parallax-bg {
animation: parallax linear both;
animation-timeline: scroll();
}
@keyframes parallax {
from {
transform: translateY(-20%);
}
to {
transform: translateY(20%);
}
}
.parallax-section {
overflow: hidden;
position: relative;
}The background image moves at a different rate than the scroll, creating a parallax effect. Smooth, performant (the browser can composite on the GPU), and zero JavaScript.
For more control, you can name scroll timelines and reference them from other elements:
.scroller {
overflow-y: auto;
scroll-timeline-name: --my-scroller;
scroll-timeline-axis: block;
}
/* An element anywhere in the DOM can reference this timeline */
.indicator {
animation: progress linear both;
animation-timeline: --my-scroller;
}
@keyframes progress {
from { width: 0%; }
to { width: 100%; }
}This works even when the indicator and the scroller aren't parent/child. Any element can link to any named scroll timeline. This is powerful for dashboard UIs where a scrollable panel drives an indicator in a fixed header.
Before anchor positioning, connecting a tooltip to its trigger element required JavaScript. You'd calculate positions with getBoundingClientRect(), handle scroll offsets, manage viewport collisions, and re-calculate on resize. Libraries like Popper.js (now Floating UI) existed specifically because this was so hard to get right.
CSS anchor positioning makes this declarative:
.trigger {
anchor-name: --my-trigger;
}
.tooltip {
position: fixed;
position-anchor: --my-trigger;
/* Position the tooltip's top-center at the trigger's bottom-center */
top: anchor(bottom);
left: anchor(center);
translate: -50% 0;
/* Fallback positioning if it overflows the viewport */
position-try-fallbacks: flip-block, flip-inline;
}The position-try-fallbacks property is the part that would have taken 200 lines of JavaScript. It tells the browser: "if the tooltip overflows the viewport at the bottom, flip it to the top. If it overflows on the right, flip it to the left."
.dropdown-menu {
position: fixed;
position-anchor: --menu-button;
/* Default: below the button, aligned to the left edge */
top: anchor(bottom);
left: anchor(left);
/* If it doesn't fit below, try above. If it doesn't fit left-aligned, try right-aligned */
position-try-fallbacks: flip-block, flip-inline;
/* Add a gap between the anchor and the dropdown */
margin-top: 4px;
}For more control over fallback positions, you can define custom try options:
@position-try --above-right {
bottom: anchor(top);
right: anchor(right);
top: auto;
left: auto;
margin-top: 0;
margin-bottom: 4px;
}
@position-try --left-center {
right: anchor(left);
top: anchor(center);
left: auto;
bottom: auto;
translate: 0 -50%;
margin-top: 0;
margin-right: 4px;
}
.tooltip {
position: fixed;
position-anchor: --trigger;
top: anchor(bottom);
left: anchor(center);
translate: -50% 0;
margin-top: 4px;
position-try-fallbacks: --above-right, --left-center;
}The browser tries each fallback in order until it finds one that keeps the element within the viewport. This is the kind of spatial reasoning that was genuinely painful in JavaScript.
Anchor positioning pairs perfectly with the new Popover API:
<button popovertarget="my-popover" style="anchor-name: --btn">Settings</button>
<div popover id="my-popover" style="
position-anchor: --btn;
top: anchor(bottom);
left: anchor(center);
translate: -50% 0;
margin-top: 8px;
">
Popover content here
</div>No JavaScript for showing/hiding (the Popover API handles that). No JavaScript for positioning (anchor positioning handles that). No JavaScript for light-dismiss behavior (the Popover API handles that too). The entire tooltip/popover/dropdown pattern — a pattern that powered entire npm packages — is now HTML and CSS.
Grid is powerful, but it had one frustrating limitation: a child grid couldn't align its items to the parent grid. If you had a row of cards and wanted the title, content, and footer of each card to align across cards, you were out of luck. Each card's internal grid was independent.
Subgrid fixes this.
/* Parent grid */
.card-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1.5rem;
}
/* Each card becomes a subgrid, aligning rows to the parent */
.card {
display: grid;
grid-row: span 3; /* Card spans 3 rows in the parent */
grid-template-rows: subgrid;
gap: 0; /* Card controls its own internal gap */
}
.card-title {
font-weight: 600;
padding: 1rem 1rem 0.5rem;
}
.card-body {
padding: 0 1rem;
color: oklch(40% 0 0);
}
.card-footer {
padding: 0.5rem 1rem 1rem;
margin-top: auto;
border-top: 1px solid oklch(90% 0 0);
}Now the titles across all three cards align on the same row. The bodies align. The footers align. Even when one card has a two-line title and another has a one-line title, the alignment is maintained by the parent grid.
Without subgrid, achieving this alignment required either:
/* The old hack — fragile and breaks with dynamic content */
.card-title {
min-height: 3rem; /* pray that no title exceeds this */
}
.card-body {
min-height: 8rem; /* even more prayer */
}Subgrid makes the hack unnecessary. The parent grid distributes row heights based on the tallest content in each row across all cards.
Subgrid works on columns too, which is perfect for form layouts:
.form {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.75rem 1rem;
}
.form-row {
display: grid;
grid-column: 1 / -1;
grid-template-columns: subgrid;
align-items: center;
}
.form-row label {
grid-column: 1;
text-align: right;
font-weight: 500;
}
.form-row input {
grid-column: 2;
padding: 0.5rem;
border: 1px solid oklch(80% 0 0);
border-radius: 0.375rem;
}All labels align. All inputs align. The label column auto-sizes to the widest label. No hardcoded widths.
This one is the most ambitious feature on this list. The View Transitions API lets you animate between page navigations — including cross-document navigations (regular link clicks in a multi-page app). Your static HTML site can now have smooth, animated page transitions.
To enable cross-document view transitions, you add a single CSS rule to both the old and new page:
@view-transition {
navigation: auto;
}That's it. The browser will now cross-fade between pages when navigating. The default transition is a smooth opacity fade. No JavaScript. No framework. Just two lines of CSS on each page.
You can customize what transitions happen by naming specific elements:
/* On both pages */
.page-header {
view-transition-name: header;
}
.main-content {
view-transition-name: content;
}
.hero-image {
view-transition-name: hero;
}
/* Transition animations */
::view-transition-old(content) {
animation: slide-out 0.3s ease-in both;
}
::view-transition-new(content) {
animation: slide-in 0.3s ease-out both;
}
::view-transition-old(hero) {
animation: fade-out 0.2s ease-in both;
}
::view-transition-new(hero) {
animation: none; /* The new hero just appears */
}
@keyframes slide-out {
to {
transform: translateX(-100%);
opacity: 0;
}
}
@keyframes slide-in {
from {
transform: translateX(100%);
opacity: 0;
}
}
@keyframes fade-out {
to {
opacity: 0;
}
}When navigating between two pages that both have a .hero-image with view-transition-name: hero, the browser automatically animates the image from its position on the old page to its position on the new page. It's the "shared element transition" pattern from mobile development, now in the browser.
For single-page apps (React, Vue, Svelte, etc.), the JavaScript API is straightforward:
/* CSS side — define your transition animations */
::view-transition-old(root) {
animation: fade-out 0.2s ease-in;
}
::view-transition-new(root) {
animation: fade-in 0.2s ease-out;
}// JS side — wrap your DOM update in startViewTransition
document.startViewTransition(() => {
// Update the DOM here — React render, innerHTML swap, etc.
updateContent(newPageData);
});The browser snapshots the old state, runs your update, snapshots the new state, and animates between them. Named elements get individual transitions; everything else gets a default crossfade.
Always respect prefers-reduced-motion:
@media (prefers-reduced-motion: reduce) {
::view-transition-group(*),
::view-transition-old(*),
::view-transition-new(*) {
animation: none !important;
}
}These features collectively eliminate a lot of tooling, libraries, and patterns that we've relied on for years. Here's what I've removed or stopped reaching for:
CSS has native nesting. CSS has custom properties (and has had them for years). The two main reasons people reached for Sass are now in the language. If you're still using Sass only for $variables and nesting, you can stop.
/* Before: Sass */
$primary: #3b82f6;
.card {
background: white;
border: 1px solid lighten($primary, 40%);
&:hover {
border-color: $primary;
}
.title {
color: darken($primary, 15%);
}
}
/* After: Native CSS */
.card {
--primary: oklch(55% 0.2 260);
background: white;
border: 1px solid color-mix(in oklch, var(--primary) 20%, white);
&:hover {
border-color: var(--primary);
}
.title {
color: color-mix(in oklch, var(--primary) 70%, black);
}
}The CSS version is more powerful — oklch gives perceptually uniform color manipulation, color-mix works at runtime (Sass lighten is compile-time only), and custom properties can be changed dynamically from JavaScript or media queries.
Delete your IntersectionObserver reveal-on-scroll code. Delete your scroll progress bar JavaScript. Delete your parallax scroll handler. animation-timeline: scroll() and animation-timeline: view() handle all of these with better performance (compositor thread, not main thread).
// Before: JavaScript you can delete
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
entry.target.classList.add('visible');
observer.unobserve(entry.target);
}
});
},
{ threshold: 0.1 }
);
document.querySelectorAll('.reveal').forEach((el) => observer.observe(el));/* After: CSS that replaces all of the above */
.reveal {
animation: reveal linear both;
animation-timeline: view();
animation-range: entry 0% entry 100%;
}
@keyframes reveal {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}If you're using Floating UI (formerly Popper.js) only for basic tooltip/popover positioning, CSS anchor positioning replaces it. You lose some of the advanced features (virtual elements, custom middleware), but for the 90% use case — "put this popover near that button and keep it in the viewport" — CSS does it natively now.
margin: auto Centering Hacks#This isn't new, but I still see margin: 0 auto everywhere. Modern layout tools make it unnecessary in most cases:
/* Old way */
.container {
max-width: 1200px;
margin: 0 auto;
padding: 0 1rem;
}
/* Better: CSS Grid or Flexbox */
.page {
display: grid;
place-items: center;
}
/* Or: container queries make the container itself responsive */
.content {
container-type: inline-size;
width: min(100% - 2rem, 1200px);
margin-inline: auto; /* OK, still auto, but margin-inline is clearer */
}margin: 0 auto still works fine. But if you find yourself using it for vertical centering or complex alignment, reach for flexbox or grid instead.
This is the big one. If you're writing @media queries to make a component responsive, you're likely doing it wrong now. Container queries should be your default for component-level responsiveness. Reserve @media queries for page-level decisions: layout changes, navigation patterns, print styles.
/* Before: Media query for component styling (wrong scope) */
@media (min-width: 768px) {
.product-card {
flex-direction: row;
}
}
/* After: Container query (right scope) */
.product-card-wrapper {
container-type: inline-size;
}
@container (min-width: 400px) {
.product-card {
flex-direction: row;
}
}These features don't exist in isolation. They compose. Container queries + :has() + nesting + oklch() + layers — used together — give you a CSS authoring experience that would have been unrecognizable five years ago:
@layer components {
.card-wrapper {
container-type: inline-size;
}
.card {
--accent: oklch(55% 0.2 260);
display: grid;
gap: 1rem;
padding: 1.5rem;
border-radius: 0.5rem;
background: white;
border: 1px solid oklch(90% 0 0);
&:has(img) {
padding-top: 0;
img {
border-radius: 0.5rem 0.5rem 0 0;
width: 100%;
aspect-ratio: 16 / 9;
object-fit: cover;
}
}
&:has(.badge.new) {
border-color: var(--accent);
}
.title {
font-size: 1.125rem;
font-weight: 600;
color: oklch(20% 0 0);
text-wrap: balance;
}
.meta {
font-size: 0.875rem;
color: oklch(50% 0 0);
}
@container (min-width: 500px) {
grid-template-columns: 200px 1fr;
&:has(img) {
padding: 0;
img {
border-radius: 0.5rem 0 0 0.5rem;
height: 100%;
aspect-ratio: auto;
}
}
.content {
padding: 1.5rem;
}
}
}
}That single block handles:
:has())oklch)@layer)No preprocessor. No CSS-in-JS runtime. No utility classes for this. Just CSS, doing what CSS should have done all along.
The platform caught up. Finally. And it was worth the wait.