Ga naar inhoud
23 min leestijd

Modern CSS in 2026: De Features Die Veranderden Hoe Ik Stijlen Schrijf

Container queries, CSS layers, :has(), color-mix(), nesting, scroll-driven animations en anchor positioning. De CSS-features die ervoor zorgden dat ik niet meer naar JavaScript greep.

Delen:X / TwitterLinkedIn

Zes maanden geleden heb ik mijn laatste Sass-bestand verwijderd. Niet omdat ik een statement wilde maken. Omdat ik het oprecht niet meer nodig had.

Meer dan tien jaar lang was CSS de taal waar we ons voor verontschuldigden. We hadden preprocessors nodig voor nesting en variabelen. We hadden JavaScript nodig voor container-gebaseerde afmetingen, scroll-gekoppelde animaties, parent selection, en de helft van de layout-patronen die designers ons aanreikten. We bouwden hele runtime-systemen — CSS-in-JS bibliotheken, utility frameworks, PostCSS plugin-ketens — om te compenseren voor wat de taal niet natively kon.

Dat tijdperk is voorbij. Niet "bijna voorbij." Niet "we komen er." Voorbij.

De features die tussen 2024 en 2026 in browsers zijn geland, voegden niet alleen gemak toe. Ze veranderden het mentale model. CSS is niet langer een stylingtaal waar je tegen vecht. Het is een stylingtaal die daadwerkelijk nadenkt over componenten, specificity-beheer, responsive design op elementniveau, en animatie zonder runtime.

Dit is wat er veranderd is, waarom het ertoe doet, en wat je kunt stoppen met doen dankzij deze veranderingen.

Container Queries: Het Einde van "Hoe Breed Is de Viewport?"#

Dit is degene die fundamenteel veranderde hoe ik over responsive design nadenk. Twintig jaar lang hadden we media queries. Media queries vragen: "hoe breed is de viewport?" Container queries vragen: "hoe breed is de container waar dit component in leeft?"

Dat onderscheid klinkt subtiel. Dat is het niet. Het is het verschil tussen componenten die alleen in een layout-context werken en componenten die overal werken.

Het Probleem Dat Twee Decennia Bestond#

Stel je een card-component voor. In een sidebar moet het verticaal gestapeld worden met een klein plaatje. In een main content-gebied moet het horizontaal worden met een groter plaatje. In een full-width hero-sectie moet het weer iets anders zijn.

Met media queries zou je zoiets schrijven:

css
/* De oude manier: componentstijlen koppelen aan paginalayout */
.card {
  display: flex;
  flex-direction: column;
}
 
@media (min-width: 768px) {
  .sidebar .card {
    /* Nog steeds verticaal in sidebar */
  }
 
  .main-content .card {
    flex-direction: row;
    /* Horizontaal in main-gebied */
  }
}
 
@media (min-width: 1200px) {
  .hero .card {
    /* Weer een andere layout */
  }
}

De card weet van .sidebar, .main-content, en .hero. Het weet van de pagina. Het is geen component meer — het is een paginabewust fragment. Verplaats het naar een andere pagina en alles breekt.

Container Queries Lossen Dit Volledig Op#

css
/* De container query-manier: component kent alleen zichzelf */
.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;
  }
}

De card weet niet waar het leeft. Het maakt niet uit. Zet het in een 300px sidebar en het is verticaal. Zet het in een 700px main-gebied en het is horizontaal. Drop het in een full-width sectie en het past zich aan. Nul kennis van de paginalayout vereist.

inline-size vs size#

Je wilt bijna altijd container-type: inline-size. Dit schakelt queries in op de inline-as (breedte in horizontale schrijfmodi). container-type: size gebruiken schakelt zowel inline- als block-as queries in, maar het vereist dat de container expliciete afmetingen heeft in beide dimensies, wat de normale documentstroom breekt in de meeste gevallen.

css
/* Dit is wat je 99% van de tijd wilt */
.wrapper {
  container-type: inline-size;
}
 
/* Dit vereist expliciete hoogte — zelden wat je wilt */
.wrapper-both {
  container-type: size;
  height: 500px; /* vereist, anders klapt het in */
}

Benoemde Containers voor Geneste Contexten#

Wanneer je containers nest, wordt naamgeving essentieel:

css
.page-layout {
  container-type: inline-size;
  container-name: layout;
}
 
.sidebar {
  container-type: inline-size;
  container-name: sidebar;
}
 
/* Richt je specifiek op de sidebar, niet de dichtstbijzijnde voorouder */
@container sidebar (max-width: 250px) {
  .nav-item {
    font-size: 0.875rem;
    padding: 0.25rem;
  }
}
 
/* Richt je op de paginalayout */
@container layout (min-width: 1200px) {
  .page-header {
    font-size: 2.5rem;
  }
}

Zonder namen bevraagt @container de dichtstbijzijnde voorouder met containment. Bij nesting is dat vaak niet de container die je wilt. Geef ze namen. Altijd.

Container Query Units#

Dit is ondergewaardeerd. Container query units (cqi, cqb, cqw, cqh) laten je dingen dimensioneren relatief aan de container, niet de viewport:

css
@container (min-width: 400px) {
  .card-title {
    font-size: clamp(1rem, 4cqi, 2rem);
  }
}

4cqi is 4% van de inline-grootte van de container. De titel schaalt mee met de container, niet het venster. Dit is wat fluid typography vanaf het begin had moeten zijn.

CSS Layers: De Specificity-Oorlog Is Voorbij#

Als container queries veranderden hoe ik over responsive design nadenk, veranderde @layer hoe ik over CSS-architectuur nadenk. Voor het eerst hebben we een verstandige, declaratieve manier om specificity over een heel project te beheren.

Het Probleem#

CSS specificity is een puntensysteem dat niet om je intenties geeft. Een utility class met .text-red verliest van .card .title omdat de laatste een hogere specificity heeft. De oplossing was altijd dezelfde: maak je selectors specifieker, voeg !important toe, of herstructureer alles.

We bouwden hele methodologieen (BEM, SMACSS, ITCSS) en toolchains alleen om specificity-conflicten te vermijden. Dat alles was een workaround voor een ontbrekende taalfeature.

Layer-Volgorde#

@layer laat je de volgorde declareren waarin groepen stijlen worden beschouwd, ongeacht de specificity binnen die groepen:

css
/* Declareer laagvolgorde — deze ene regel bepaalt alles */
@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);
  }
}

Hoewel .card .title een hogere specificity heeft dan .text-red, wint de utility omdat de utilities-laag na components is gedeclareerd. Geen !important. Geen specificity-hacks. De laagvolgorde heeft het laatste woord.

Hoe Tailwind CSS v4 Layers Gebruikt#

Tailwind v4 leunt sterk op @layer. Wanneer je @import "tailwindcss" schrijft, krijg je:

css
@layer theme, base, components, utilities;

Elke Tailwind-utility leeft in de utilities-laag. Je eigen componentstijlen gaan in components. Dit is waarom een text-red-500 class de kleur van een component kan overschrijven zonder !important nodig te hebben — het zit in een latere laag.

Als je je eigen design system bouwt zonder Tailwind, steel deze architectuur. Het is de juiste:

css
@layer reset, tokens, base, layouts, components, utilities, overrides;

Zeven lagen is voldoende. Ik heb er nooit meer nodig gehad.

Ongelaagde Stijlen Winnen van Alles#

Een valkuil: stijlen die niet in een laag zitten hebben de hoogste prioriteit. Dit is eigenlijk nuttig — het betekent dat je eenmalige paginaspecifieke overschrijvingen automatisch winnen:

css
@layer components {
  .modal {
    background: white;
  }
}
 
/* Niet in een laag — wint van alles in lagen */
.special-page .modal {
  background: oklch(95% 0.02 260);
}

Maar het betekent ook dat CSS van derden die niet layer-aware is je hele systeem kan overschrijven. Wrap stijlen van derden in een laag om ze te beheersen:

css
@layer third-party {
  @import url("some-library.css");
}

De :has() Selector: De Parent Selector Die We Altijd Wilden#

Letterlijk decennialang vroegen developers om een parent selector. "Ik wil een parent stijlen op basis van zijn kinderen." Het antwoord was altijd "CSS kan dat niet" gevolgd door een JavaScript-workaround. :has() verandert dit compleet, en het blijkt zelfs krachtiger dan wat we vroegen.

Eenvoudige Parent Selection#

css
/* Stijl een formuliergroep wanneer zijn input focus heeft */
.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);
}
 
/* Stijl een card anders wanneer het een afbeelding bevat */
.card:has(img) {
  padding-top: 0;
}
 
.card:has(img) .card-content {
  padding: 1.5rem;
}
 
/* Een card zonder afbeelding krijgt een andere behandeling */
.card:not(:has(img)) {
  border-left: 4px solid oklch(55% 0.2 260);
}

Formuliervalidatie zonder JavaScript#

Hier wordt :has() echt spannend. Gecombineerd met HTML-validatie pseudo-classes kun je formulier-UI's bouwen die reageren op validatiestatus met nul JavaScript:

css
/* De veldwrapper reageert op de validiteit van zijn input */
.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;
}

Het :not(:placeholder-shown) deel is cruciaal — het voorkomt dat validatiestijlen verschijnen op lege velden die nog niet zijn aangeraakt.

Layout Op Basis van Aantal Kinderen#

Dit patroon is absurd nuttig en was oprecht onmogelijk voor :has():

css
/* Pas grid-kolommen aan op basis van hoeveel items er zijn */
.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);
}

Het grid verandert zijn aantal kolommen op basis van hoeveel kinderen het heeft. Geen JavaScript. Geen ResizeObserver. Geen class-toggling.

Een van mijn favoriete patronen — de layout veranderen op basis van of een sidebar bestaat:

css
.page-layout {
  display: grid;
  grid-template-columns: 1fr;
  gap: 2rem;
}
 
/* Als de layout een sidebar bevat, schakel naar twee kolommen */
.page-layout:has(.sidebar) {
  grid-template-columns: 1fr 300px;
}
 
/* Pas de breedte van de hoofdinhoud aan wanneer er een sidebar is */
.page-layout:has(.sidebar) .main-content {
  max-width: 65ch;
}

Voeg een sidebar-component toe aan de DOM, de layout past zich aan. Verwijder het, het past zich weer aan. De CSS is de bron van waarheid, niet een state-variabele.

:has() Combineren met Andere Selectors#

:has() composeert prachtig met al het andere:

css
/* Stijl een artikel alleen wanneer het een specifieke klasse van figure heeft */
article:has(figure.full-bleed) {
  overflow: visible;
}
 
/* Een navigatie die verandert wanneer het een zoekvenster bevat */
nav:has(input[type="search"]:focus) {
  background: oklch(98% 0 0);
  box-shadow: 0 2px 8px oklch(0% 0 0 / 0.08);
}
 
/* Schakel dark mode in op componentniveau */
.theme-switch:has(input:checked) ~ main {
  color-scheme: dark;
  background: oklch(15% 0 0);
  color: oklch(90% 0 0);
}

Browserondersteuning is nu uitstekend. Elke moderne browser ondersteunt :has() sinds begin 2024. Er is geen reden om het niet te gebruiken.

CSS Nesting: Native, Eindelijk Stabiel#

Ik ga deze niet overdrijven. CSS nesting is fijn. Het is niet revolutionair op de manier waarop container queries of :has() dat zijn. Maar het verwijdert een van de laatste redenen om Sass te gebruiken, en dat is belangrijk.

De Syntax#

css
.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);
  }
}

Dit is geldige CSS. Geen build-stap. Geen preprocessor. Geen PostCSS-plugin. De browser handelt het natively af.

Verschillen met Sass#

Er zijn een paar syntaxverschillen die het weten waard zijn:

css
/* CSS Nesting — werkt nu in alle browsers */
.parent {
  /* Directe class/element nesting werkt zonder & */
  .child {
    color: red;
  }
 
  /* & is vereist voor pseudo-classes en samengestelde selectors */
  &:hover {
    background: blue;
  }
 
  &.active {
    font-weight: bold;
  }
 
  /* Geneste media queries — dit is geweldig */
  @media (width >= 768px) {
    flex-direction: row;
  }
}

In vroege implementaties had je & nodig voor elementselectors (zoals & p in plaats van gewoon p). Die beperking is opgeheven. Sinds 2025 ondersteunen alle grote browsers kale element-nesting: .parent { p { ... } } werkt prima.

Geneste Media Queries#

Dit is de killer feature van CSS nesting, naar mijn mening. Niet de selector-nesting — de mogelijkheid om media queries binnen een regelblok te plaatsen:

css
.hero {
  padding: 2rem;
  font-size: 1rem;
 
  @media (width >= 768px) {
    padding: 4rem;
    font-size: 1.25rem;
  }
 
  @media (width >= 1200px) {
    padding: 6rem;
    font-size: 1.5rem;
  }
}

Vergelijk dit met de oude manier waar je .hero-stijlen verspreid waren over drie verschillende @media-blokken, mogelijk honderden regels uit elkaar. Nesting houdt het responsieve gedrag bij het component. De leesbaarheid verbetert dramatisch.

Niet Te Diep Nesten#

Een waarschuwing: alleen omdat je kunt nesten op zes niveaus diep, betekent niet dat je dat moet. Hetzelfde advies als bij Sass geldt hier. Als je nesting selectors creert zoals .page .section .card .content .text .highlight, heb je een specificity-monster en een onderhoudsachtmerrie gecreeerd. Twee of drie niveaus is de sweet spot.

css
/* Goed — twee niveaus */
.nav {
  .link {
    color: inherit;
 
    &:hover {
      color: oklch(55% 0.2 260);
    }
  }
}
 
/* Slecht — specificity-nachtmerrie */
.page {
  .layout {
    .sidebar {
      .nav {
        .list {
          .item {
            .link {
              color: red; /* succes met het overschrijven hiervan */
            }
          }
        }
      }
    }
  }
}

Kleurfuncties: oklch() en color-mix() Veranderen Alles#

hsl() heeft een goede run gehad. Het was intuiever dan rgb(). Maar het heeft een fundamenteel gebrek: het is niet perceptueel uniform. Een hsl(60, 100%, 50%) (geel) ziet er dramatisch lichter uit voor het menselijk oog dan hsl(240, 100%, 50%) (blauw), ook al hebben ze dezelfde lichtheidswaarde.

Waarom oklch() Wint#

oklch() is perceptueel uniform. Gelijke lichtheidswaarden zien er gelijk licht uit. Dit is enorm belangrijk bij het genereren van kleurpaletten, het maken van thema's en het garanderen van toegankelijk contrast:

css
:root {
  /* oklch(lichtheid chroma tint) */
  --color-primary: oklch(55% 0.2 260);     /* blauw */
  --color-secondary: oklch(55% 0.2 330);   /* paars */
  --color-success: oklch(55% 0.2 145);     /* groen */
  --color-danger: oklch(55% 0.25 25);      /* rood */
  --color-warning: oklch(75% 0.18 85);     /* geel — hogere L voor gelijke perceptie */
 
  /* Deze zien er allemaal even "medium" uit voor het menselijk oog */
  /* Met HSL zou je verschillende lichtheidswaarden nodig hebben voor elke tint */
}

De drie waarden zijn intuitief zodra je ze begrijpt:

  • Lichtheid (0% tot 100%): Hoe licht of donker. 0% is zwart, 100% is wit.
  • Chroma (0 tot ~0,37): Hoe levendig. 0 is grijs, hoger is meer verzadigd.
  • Tint (0 tot 360): De kleurhoek. 0/360 is rozigig rood, 145 is groen, 260 is blauw.

color-mix() voor Afgeleide Kleuren#

color-mix() laat je kleuren maken van andere kleuren tijdens runtime. Geen Sass darken() functie. Geen JavaScript. Gewoon CSS:

css
:root {
  --brand: oklch(55% 0.2 260);
 
  /* Lichter maken door te mengen met wit */
  --brand-light: color-mix(in oklch, var(--brand) 30%, white);
 
  /* Donkerder maken door te mengen met zwart */
  --brand-dark: color-mix(in oklch, var(--brand) 70%, black);
 
  /* Maak een subtiele achtergrond */
  --brand-bg: color-mix(in oklch, var(--brand) 8%, white);
 
  /* Semi-transparante versie */
  --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);
  }
}

Het in oklch deel is belangrijk. Mengen in srgb geeft modderige tussenliggende kleuren. Mengen in oklch geeft perceptueel gelijkmatige resultaten. Meng altijd in oklch.

Een Compleet Palet Bouwen met oklch()#

Zo genereer ik een heel tintenscala vanuit een enkele tint:

css
: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));
}

Verander --hue naar 145 en je hebt een groen palet. Verander het naar 25 en je hebt rood. De lichtigheidsstappen zijn perceptueel gelijkmatig. De chroma neemt af aan de uiteinden zodat de lichtste en donkerste tinten niet oververzadigd zijn. Dit is het soort ding dat vroeger een designtool of een Sass-functie vereiste. Nu is het acht regels CSS.

Scroll-Driven Animations: Geen JavaScript Nodig#

Dit is de feature waardoor ik de meeste JavaScript heb verwijderd. Scroll-gekoppelde animaties — voortgangsbalken, parallax-effecten, reveal-animaties, sticky headers met transitions — vereisten vroeger IntersectionObserver, scroll event listeners, of een bibliotheek zoals GSAP. Nu is het CSS.

Scroll-Voortgangsindicator#

De klassieke "leesvoortgangsbalk" bovenaan een artikelpagina:

css
.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);
  }
}

Dat is het. De hele leesvoortgangsindicator. Geen JavaScript. Geen scroll event listener. Geen requestAnimationFrame. Geen "percentage gescrold" berekening. De animation-timeline: scroll() binding doet alles.

Reveal Bij Scrollen#

Elementen die infaden wanneer ze de viewport binnenkomen:

css
.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() koppelt de animatie aan de zichtbaarheid van het element in de viewport. animation-range: entry 0% entry 100% betekent dat de animatie draait vanaf het moment dat het element begint de viewport binnen te komen totdat het volledig zichtbaar is.

Parallax Zonder Bibliotheek#

css
.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;
}

De achtergrondafbeelding beweegt met een ander tempo dan de scroll, wat een parallax-effect creert. Soepel, performant (de browser kan compositen op de GPU), en nul JavaScript.

Benoemde Scroll Timelines#

Voor meer controle kun je scroll timelines benoemen en ze refereren vanuit andere elementen:

css
.scroller {
  overflow-y: auto;
  scroll-timeline-name: --my-scroller;
  scroll-timeline-axis: block;
}
 
/* Een element waar dan ook in de DOM kan deze timeline refereren */
.indicator {
  animation: progress linear both;
  animation-timeline: --my-scroller;
}
 
@keyframes progress {
  from { width: 0%; }
  to { width: 100%; }
}

Dit werkt zelfs wanneer de indicator en de scroller geen parent/child zijn. Elk element kan linken naar een benoemde scroll timeline. Dit is krachtig voor dashboard-UI's waar een scrollbaar paneel een indicator in een vaste header aanstuurt.

Anchor Positioning: Tooltips en Popovers Goed Gedaan#

Voor anchor positioning vereiste het verbinden van een tooltip aan zijn trigger-element JavaScript. Je berekende posities met getBoundingClientRect(), handelde scroll-offsets af, beheerde viewport-botsingen, en herberekende bij resize. Bibliotheken zoals Popper.js (nu Floating UI) bestonden specifiek omdat dit zo moeilijk was om goed te krijgen.

CSS anchor positioning maakt dit declaratief:

css
.trigger {
  anchor-name: --my-trigger;
}
 
.tooltip {
  position: fixed;
  position-anchor: --my-trigger;
 
  /* Positioneer het midden-boven van de tooltip op het midden-onder van de trigger */
  top: anchor(bottom);
  left: anchor(center);
  translate: -50% 0;
 
  /* Fallback-positionering als het de viewport overstroomt */
  position-try-fallbacks: flip-block, flip-inline;
}

Automatische Viewport-Botsingsafhandeling#

De position-try-fallbacks property is het deel dat 200 regels JavaScript zou kosten. Het vertelt de browser: "als de tooltip onderaan de viewport overstroomt, draai het naar boven. Als het rechts overstroomt, draai het naar links."

css
.dropdown-menu {
  position: fixed;
  position-anchor: --menu-button;
 
  /* Standaard: onder de knop, uitgelijnd op de linkerrand */
  top: anchor(bottom);
  left: anchor(left);
 
  /* Als het niet past onderaan, probeer bovenaan. Als het niet past links-uitgelijnd, probeer rechts-uitgelijnd */
  position-try-fallbacks: flip-block, flip-inline;
 
  /* Voeg een tussenruimte toe tussen het anker en het dropdown */
  margin-top: 4px;
}

Benoemde Positie-Fallbacks#

Voor meer controle over fallback-posities kun je aangepaste try-opties definieren:

css
@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;
}

De browser probeert elke fallback in volgorde totdat het er een vindt die het element binnen de viewport houdt. Dit is het soort ruimtelijk redeneren dat oprecht pijnlijk was in JavaScript.

Anchoring met de Popover API#

Anchor positioning werkt perfect samen met de nieuwe Popover API:

html
<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-inhoud hier
</div>

Geen JavaScript voor tonen/verbergen (de Popover API regelt dat). Geen JavaScript voor positionering (anchor positioning regelt dat). Geen JavaScript voor light-dismiss gedrag (de Popover API regelt dat ook). Het hele tooltip/popover/dropdown-patroon — een patroon dat hele npm-pakketten aandreef — is nu HTML en CSS.

CSS Grid Subgrid: Geneste Uitlijning Die Echt Werkt#

Grid is krachtig, maar het had een frustrerende beperking: een child grid kon zijn items niet uitlijnen op het parent grid. Als je een rij kaarten had en wilde dat de titel, inhoud en footer van elke kaart over kaarten heen uitlijnen, had je pech. Het interne grid van elke kaart was onafhankelijk.

Subgrid lost dit op.

Het Kaartuitlijningsprobleem#

css
/* Parent grid */
.card-grid {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 1.5rem;
}
 
/* Elke kaart wordt een subgrid, rijen uitgelijnd op de parent */
.card {
  display: grid;
  grid-row: span 3; /* Kaart beslaat 3 rijen in de parent */
  grid-template-rows: subgrid;
  gap: 0; /* Kaart bepaalt zijn eigen interne tussenruimte */
}
 
.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);
}

Nu lijnen de titels over alle drie kaarten uit op dezelfde rij. De bodies lijnen uit. De footers lijnen uit. Zelfs wanneer een kaart een tweeregelige titel heeft en een andere een eenregelige titel, wordt de uitlijning gehandhaafd door het parent grid.

Voor Subgrid: De Hacks#

Zonder subgrid vereiste het bereiken van deze uitlijning:

  1. Vaste hoogtes (fragiel, breekt met dynamische inhoud)
  2. JavaScript-metingen (traag, flits van verkeerde uitlijning)
  3. Opgeven en misalignment accepteren (veelvoorkomend, lelijk)
css
/* De oude hack — fragiel en breekt met dynamische inhoud */
.card-title {
  min-height: 3rem; /* bid dat geen titel dit overschrijdt */
}
 
.card-body {
  min-height: 8rem; /* nog meer gebeden */
}

Subgrid maakt de hack onnodig. Het parent grid verdeelt rijhoogtes op basis van de langste inhoud in elke rij over alle kaarten.

Kolom-Subgrid voor Formulierlay-outs#

Subgrid werkt ook op kolommen, wat perfect is voor formulierlay-outs:

css
.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;
}

Alle labels lijnen uit. Alle inputs lijnen uit. De labelkolom past zich automatisch aan op het breedste label. Geen hardgecodeerde breedtes.

View Transitions API: SPA-Achtige Navigatie Zonder Framework#

Dit is de meest ambitieuze feature op deze lijst. De View Transitions API laat je animeren tussen paginanavigaties — inclusief cross-document navigaties (gewone linkklikken in een multi-page app). Je statische HTML-site kan nu soepele, geanimeerde paginatransities hebben.

Cross-Document Transitions#

Om cross-document view transitions in te schakelen, voeg je een enkele CSS-regel toe aan zowel de oude als de nieuwe pagina:

css
@view-transition {
  navigation: auto;
}

Dat is het. De browser zal nu cross-faden tussen pagina's bij het navigeren. De standaard transitie is een soepele opacity-fade. Geen JavaScript. Geen framework. Gewoon twee regels CSS op elke pagina.

De Transitie Aanpassen#

Je kunt aanpassen welke transitions plaatsvinden door specifieke elementen te benoemen:

css
/* Op beide pagina's */
.page-header {
  view-transition-name: header;
}
 
.main-content {
  view-transition-name: content;
}
 
.hero-image {
  view-transition-name: hero;
}
 
/* Transitie-animaties */
::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; /* De nieuwe hero verschijnt gewoon */
}
 
@keyframes slide-out {
  to {
    transform: translateX(-100%);
    opacity: 0;
  }
}
 
@keyframes slide-in {
  from {
    transform: translateX(100%);
    opacity: 0;
  }
}
 
@keyframes fade-out {
  to {
    opacity: 0;
  }
}

Wanneer je navigeert tussen twee pagina's die beide een .hero-image met view-transition-name: hero hebben, animeert de browser de afbeelding automatisch van zijn positie op de oude pagina naar zijn positie op de nieuwe pagina. Het is het "shared element transition"-patroon uit mobiele ontwikkeling, nu in de browser.

SPA View Transitions#

Voor single-page apps (React, Vue, Svelte, etc.) is de JavaScript API eenvoudig:

css
/* CSS-kant — definieer je transitie-animaties */
::view-transition-old(root) {
  animation: fade-out 0.2s ease-in;
}
 
::view-transition-new(root) {
  animation: fade-in 0.2s ease-out;
}
javascript
// JS-kant — wrap je DOM-update in startViewTransition
document.startViewTransition(() => {
  // Werk de DOM hier bij — React render, innerHTML swap, etc.
  updateContent(newPageData);
});

De browser maakt een snapshot van de oude toestand, voert je update uit, maakt een snapshot van de nieuwe toestand, en animeert ertussen. Benoemde elementen krijgen individuele transitions; al het andere krijgt een standaard crossfade.

Gebruikersvoorkeuren Respecteren#

Respecteer altijd prefers-reduced-motion:

css
@media (prefers-reduced-motion: reduce) {
  ::view-transition-group(*),
  ::view-transition-old(*),
  ::view-transition-new(*) {
    animation: none !important;
  }
}

Wat Je Kunt Stoppen Met Gebruiken#

Deze features elimineren gezamenlijk veel tooling, bibliotheken en patronen waar we jarenlang op vertrouwden. Dit is wat ik heb verwijderd of niet meer gebruik:

Sass/SCSS voor Nesting en Variabelen#

CSS heeft native nesting. CSS heeft custom properties (en heeft die al jaren). De twee belangrijkste redenen waarom mensen naar Sass grepen zitten nu in de taal. Als je Sass nog steeds alleen voor $variables en nesting gebruikt, kun je stoppen.

css
/* Voorheen: Sass */
$primary: #3b82f6;
 
.card {
  background: white;
  border: 1px solid lighten($primary, 40%);
 
  &:hover {
    border-color: $primary;
  }
 
  .title {
    color: darken($primary, 15%);
  }
}
 
/* Daarna: 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);
  }
}

De CSS-versie is krachtiger — oklch geeft perceptueel uniforme kleurmanipulatie, color-mix werkt tijdens runtime (Sass lighten is alleen compile-time), en custom properties kunnen dynamisch worden aangepast vanuit JavaScript of media queries.

JavaScript voor Scroll-Animaties#

Verwijder je IntersectionObserver reveal-on-scroll code. Verwijder je JavaScript voor de scroll-voortgangsbalk. Verwijder je parallax scroll handler. animation-timeline: scroll() en animation-timeline: view() handelen dit allemaal af met betere prestaties (compositor thread, niet de main thread).

javascript
// Voorheen: JavaScript die je kunt verwijderen
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));
css
/* Daarna: CSS die al het bovenstaande vervangt */
.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); }
}

Popper.js / Floating UI voor Positionering#

Als je Floating UI (voorheen Popper.js) alleen gebruikt voor basis tooltip/popover-positionering, vervangt CSS anchor positioning het. Je verliest een aantal geavanceerde features (virtuele elementen, aangepaste middleware), maar voor de 90% use case — "zet deze popover bij die knop en houd het in de viewport" — doet CSS het nu natively.

margin: auto Centering-Hacks#

Dit is niet nieuw, maar ik zie nog steeds overal margin: 0 auto. Moderne layout-tools maken het in de meeste gevallen overbodig:

css
/* Oude manier */
.container {
  max-width: 1200px;
  margin: 0 auto;
  padding: 0 1rem;
}
 
/* Beter: CSS Grid of Flexbox */
.page {
  display: grid;
  place-items: center;
}
 
/* Of: container queries maken de container zelf responsive */
.content {
  container-type: inline-size;
  width: min(100% - 2rem, 1200px);
  margin-inline: auto; /* OK, nog steeds auto, maar margin-inline is duidelijker */
}

margin: 0 auto werkt nog prima. Maar als je het gebruikt voor verticale centrering of complexe uitlijning, pak dan flexbox of grid.

Media Queries voor Responsiviteit op Componentniveau#

Dit is de grote. Als je @media queries schrijft om een component responsive te maken, doe je het nu waarschijnlijk fout. Container queries moeten je standaard zijn voor responsiviteit op componentniveau. Bewaar @media queries voor beslissingen op paginaniveau: layoutwijzigingen, navigatiepatronen, printstijlen.

css
/* Voorheen: Media query voor componentstyling (verkeerde scope) */
@media (min-width: 768px) {
  .product-card {
    flex-direction: row;
  }
}
 
/* Daarna: Container query (juiste scope) */
.product-card-wrapper {
  container-type: inline-size;
}
 
@container (min-width: 400px) {
  .product-card {
    flex-direction: row;
  }
}

Het Grotere Plaatje#

Deze features bestaan niet op zichzelf. Ze componeren. Container queries + :has() + nesting + oklch() + layers — samen gebruikt — geven je een CSS-schrijfervaring die vijf jaar geleden onherkenbaar zou zijn geweest:

css
@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;
      }
    }
  }
}

Dat ene blok handelt af:

  • Responsive layout op componentniveau (container queries)
  • Voorwaardelijke styling op basis van inhoud (:has())
  • Schone selectororganisatie (nesting)
  • Perceptueel uniforme kleuren (oklch)
  • Voorspelbaar specificity-beheer (@layer)

Geen preprocessor. Geen CSS-in-JS runtime. Geen utility classes hiervoor. Gewoon CSS, dat doet wat CSS al die tijd had moeten doen.

Het platform heeft ons ingehaald. Eindelijk. En het was het wachten waard.

Gerelateerde artikelen