Zum Inhalt springen
·22 Min. Lesezeit

Modernes CSS in 2026: Die Features, die meine Art zu stylen verändert haben

Container Queries, CSS Layers, :has(), color-mix(), Nesting, Scroll-driven Animations und Anchor Positioning. Die CSS-Features, die mich davon abgehalten haben, nach JavaScript zu greifen.

Teilen:X / TwitterLinkedIn

Ich habe meine letzte Sass-Datei vor sechs Monaten gelöscht. Nicht weil ich ein Statement setzen wollte. Weil ich sie wirklich nicht mehr brauchte.

Über ein Jahrzehnt war CSS die Sprache, für die wir uns entschuldigt haben. Wir brauchten Präprozessoren für Nesting und Variablen. Wir brauchten JavaScript für container-basierte Größenbestimmung, scroll-verknüpfte Animationen, Parent-Selection und die Hälfte der Layout-Patterns, die Designer uns gaben. Wir bauten ganze Runtime-Systeme — CSS-in-JS-Bibliotheken, Utility-Frameworks, PostCSS-Plugin-Ketten — um zu kompensieren, was die Sprache nativ nicht konnte.

Diese Ära ist vorbei. Nicht „fast vorbei." Nicht „auf dem Weg." Vorbei.

Die Features, die zwischen 2024 und 2026 in Browsern gelandet sind, haben nicht nur Komfort hinzugefügt. Sie haben das mentale Modell verändert. CSS ist nicht mehr eine Styling-Sprache, gegen die du kämpfst. Es ist eine Styling-Sprache, die tatsächlich an Komponenten denkt, an Spezifitätsmanagement, an responsives Design auf Element-Ebene und an Animationen ohne Runtime.

Hier ist, was sich geändert hat, warum es wichtig ist und was du deshalb nicht mehr tun musst.

Container Queries: Das Ende von „Wie breit ist der Viewport?"#

Das ist das Feature, das grundlegend verändert hat, wie ich über responsives Design denke. Zwanzig Jahre lang hatten wir Media Queries. Media Queries fragen: „Wie breit ist der Viewport?" Container Queries fragen: „Wie breit ist der Container, in dem diese Komponente lebt?"

Dieser Unterschied klingt subtil. Ist er nicht. Es ist der Unterschied zwischen Komponenten, die nur in einem Layout-Kontext funktionieren, und Komponenten, die überall funktionieren.

Das Problem, das zwei Jahrzehnte existierte#

Betrachte eine Card-Komponente. In einer Sidebar sollte sie vertikal mit einem kleinen Bild stacken. Im Hauptinhaltsbereich sollte sie horizontal mit einem größeren Bild sein. In einem Full-Width-Hero-Bereich etwas ganz anderes.

Mit Media Queries hast du so etwas geschrieben:

css
/* Der alte Weg: Komponenten-Styles an Page-Layout koppeln */
.card {
  display: flex;
  flex-direction: column;
}
 
@media (min-width: 768px) {
  .sidebar .card {
    /* Immer noch vertikal in der Sidebar */
  }
 
  .main-content .card {
    flex-direction: row;
    /* Horizontal im Hauptbereich */
  }
}
 
@media (min-width: 1200px) {
  .hero .card {
    /* Noch ein anderes Layout */
  }
}

Die Card kennt .sidebar, .main-content und .hero. Sie kennt die Seite. Sie ist keine Komponente mehr — sie ist ein seitenabhängiges Fragment. Verschiebe sie auf eine andere Seite und alles bricht zusammen.

Container Queries beheben das vollständig#

css
/* Der Container-Query-Weg: Komponente kennt nur sich selbst */
.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;
  }
}

Die Card weiß nicht, wo sie lebt. Es ist ihr egal. Setz sie in eine 300px-Sidebar und sie ist vertikal. Setz sie in einen 700px-Hauptbereich und sie ist horizontal. Wirf sie in einen Full-Width-Bereich und sie passt sich an. Null Wissen über das Seitenlayout nötig.

inline-size vs size#

Du wirst fast immer container-type: inline-size verwenden wollen. Das aktiviert Abfragen auf der Inline-Achse (Breite in horizontalen Schreibmodi). Die Verwendung von container-type: size aktiviert Abfragen auf beiden Achsen, erfordert aber, dass der Container in beiden Dimensionen explizite Größen hat, was in den meisten Fällen den normalen Dokumentenfluss bricht.

css
/* Das willst du in 99% der Fälle */
.wrapper {
  container-type: inline-size;
}
 
/* Das erfordert explizite Höhe — selten was du willst */
.wrapper-both {
  container-type: size;
  height: 500px; /* erforderlich, sonst kollabiert es */
}

Benannte Container für verschachtelte Kontexte#

Wenn du Container verschachtelst, wird Benennung essentiell:

css
.page-layout {
  container-type: inline-size;
  container-name: layout;
}
 
.sidebar {
  container-type: inline-size;
  container-name: sidebar;
}
 
/* Gezielt die Sidebar ansprechen, nicht den nächsten Vorfahren */
@container sidebar (max-width: 250px) {
  .nav-item {
    font-size: 0.875rem;
    padding: 0.25rem;
  }
}
 
/* Das Seitenlayout ansprechen */
@container layout (min-width: 1200px) {
  .page-header {
    font-size: 2.5rem;
  }
}

Ohne Namen fragt @container den nächsten Vorfahren mit Containment ab. Bei Verschachtelung ist das oft nicht der Container, den du willst. Benenne sie. Immer.

Container Query Units#

Das wird unterschätzt. Container Query Units (cqi, cqb, cqw, cqh) lassen dich Dinge relativ zum Container dimensionieren, nicht zum Viewport:

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

4cqi ist 4% der Inline-Größe des Containers. Der Titel skaliert mit dem Container, nicht mit dem Fenster. So hätte fluide Typografie von Anfang an sein sollen.

CSS Layers: Der Spezifitätskrieg ist vorbei#

Wenn Container Queries verändert haben, wie ich über responsives Design denke, hat @layer verändert, wie ich über CSS-Architektur denke. Zum ersten Mal haben wir eine vernünftige, deklarative Methode, Spezifität über ein ganzes Projekt hinweg zu verwalten.

Das Problem#

CSS-Spezifität ist ein Punktesystem, das sich nicht für deine Absichten interessiert. Eine Utility-Klasse mit .text-red verliert gegen .card .title, weil letztere höhere Spezifität hat. Der Fix war immer derselbe: Selektoren spezifischer machen, !important hinzufügen oder alles umstrukturieren.

Wir haben ganze Methodologien (BEM, SMACSS, ITCSS) und Toolchains gebaut, nur um Spezifitätskonflikte zu vermeiden. All das war ein Workaround für ein fehlendes Sprachfeature.

Layer-Reihenfolge#

@layer lässt dich die Reihenfolge deklarieren, in der Gruppen von Styles berücksichtigt werden, unabhängig von der Spezifität innerhalb dieser Gruppen:

css
/* Layer-Reihenfolge deklarieren — diese einzelne Zeile kontrolliert 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);
  }
}

Obwohl .card .title höhere Spezifität hat als .text-red, gewinnt die Utility, weil der utilities-Layer nach components deklariert wird. Kein !important. Keine Spezifitäts-Hacks. Die Layer-Reihenfolge hat das letzte Wort.

Wie Tailwind CSS v4 Layers nutzt#

Tailwind v4 setzt stark auf @layer. Wenn du @import "tailwindcss" schreibst, bekommst du:

css
@layer theme, base, components, utilities;

Jede Tailwind-Utility lebt im utilities-Layer. Deine eigenen Komponenten-Styles gehen in components. Deshalb kann eine text-red-500-Klasse die Farbe einer Komponente überschreiben, ohne !important zu brauchen — sie ist in einem späteren Layer.

Wenn du dein eigenes Design-System ohne Tailwind baust, übernimm diese Architektur. Sie ist die richtige:

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

Sieben Layer reichen aus. Ich habe nie mehr gebraucht.

Styles ohne Layer gewinnen über alles#

Ein Haken: Styles, die in keinem Layer sind, haben die höchste Priorität. Das ist tatsächlich nützlich — es bedeutet, deine einmaligen seitenspezifischen Overrides gewinnen automatisch:

css
@layer components {
  .modal {
    background: white;
  }
}
 
/* Nicht in einem Layer — gewinnt über alles in Layers */
.special-page .modal {
  background: oklch(95% 0.02 260);
}

Aber es bedeutet auch, dass Third-Party-CSS, das nicht layer-fähig ist, dein gesamtes System überschreiben kann. Wickle Third-Party-Styles in einen Layer, um sie zu kontrollieren:

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

Der :has()-Selektor: Der Parent-Selektor, den wir immer wollten#

Buchstäblich jahrzehntelang fragten Entwickler nach einem Parent-Selektor. „Ich will ein Elternelement basierend auf seinen Kindern stylen." Die Antwort war immer „CSS kann das nicht", gefolgt von einem JavaScript-Workaround. :has() ändert das komplett, und es stellt sich heraus, dass es sogar mächtiger ist als das, worum wir gebeten haben.

Einfache Parent-Selection#

css
/* Ein Formularfeld stylen, wenn sein Input Fokus hat */
.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);
}
 
/* Eine Card anders stylen, wenn sie ein Bild enthält */
.card:has(img) {
  padding-top: 0;
}
 
.card:has(img) .card-content {
  padding: 1.5rem;
}
 
/* Eine Card ohne Bild bekommt andere Behandlung */
.card:not(:has(img)) {
  border-left: 4px solid oklch(55% 0.2 260);
}

Formularvalidierung ohne JavaScript#

Hier wird :has() richtig spannend. Kombiniert mit HTML-Validierungs-Pseudo-Klassen kannst du Formular-UIs bauen, die auf den Validierungsstatus reagieren — mit null JavaScript:

css
/* Der Feld-Wrapper reagiert auf die Validität seines Inputs */
.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;
}

Das :not(:placeholder-shown) ist entscheidend — es verhindert, dass Validierungs-Styles auf leeren Feldern erscheinen, die noch nicht berührt wurden.

Layout basierend auf Kinder-Anzahl#

Dieses Muster ist absurd nützlich und war vor :has() wirklich unmöglich:

css
/* Grid-Spalten anpassen basierend darauf, wie viele Elemente existieren */
.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);
}

Das Grid ändert seine Spaltenanzahl basierend darauf, wie viele Kinder es hat. Kein JavaScript. Kein ResizeObserver. Kein Klassen-Toggling.

Eines meiner Lieblingsmuster — das Layout ändern basierend darauf, ob eine Sidebar existiert:

css
.page-layout {
  display: grid;
  grid-template-columns: 1fr;
  gap: 2rem;
}
 
/* Wenn das Layout eine Sidebar enthält, auf zwei Spalten wechseln */
.page-layout:has(.sidebar) {
  grid-template-columns: 1fr 300px;
}
 
/* Hauptinhaltsbreite anpassen wenn Sidebar vorhanden */
.page-layout:has(.sidebar) .main-content {
  max-width: 65ch;
}

Füge eine Sidebar-Komponente ins DOM ein, das Layout passt sich an. Entferne sie, es passt sich zurück an. CSS ist die Quelle der Wahrheit, nicht eine State-Variable.

:has() mit anderen Selektoren kombinieren#

:has() komponiert wunderbar mit allem anderen:

css
/* Einen Artikel nur stylen, wenn er eine bestimmte Klasse von Figure hat */
article:has(figure.full-bleed) {
  overflow: visible;
}
 
/* Eine Navigation, die sich ändert wenn sie ein Suchfeld enthält */
nav:has(input[type="search"]:focus) {
  background: oklch(98% 0 0);
  box-shadow: 0 2px 8px oklch(0% 0 0 / 0.08);
}
 
/* Dark-Mode auf Komponentenebene aktivieren */
.theme-switch:has(input:checked) ~ main {
  color-scheme: dark;
  background: oklch(15% 0 0);
  color: oklch(90% 0 0);
}

Die Browser-Unterstützung ist mittlerweile hervorragend. Jeder moderne Browser unterstützt :has() seit Anfang 2024. Es gibt keinen Grund, es nicht zu verwenden.

CSS Nesting: Nativ, endlich stabil#

Ich werde dieses Feature nicht überverkaufen. CSS Nesting ist nett. Es ist nicht revolutionär wie Container Queries oder :has(). Aber es beseitigt einen der letzten Gründe, Sass zu verwenden, und das zählt.

Die 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);
  }
}

Das ist valides CSS. Kein Build-Schritt. Kein Präprozessor. Kein PostCSS-Plugin. Der Browser handhabt es nativ.

Unterschiede zu Sass#

Es gibt ein paar Syntax-Unterschiede, die es wert sind zu kennen:

css
/* CSS Nesting — funktioniert jetzt in allen Browsern */
.parent {
  /* Direkte Klassen-/Element-Verschachtelung funktioniert ohne & */
  .child {
    color: red;
  }
 
  /* & ist erforderlich für Pseudo-Klassen und zusammengesetzte Selektoren */
  &:hover {
    background: blue;
  }
 
  &.active {
    font-weight: bold;
  }
 
  /* Verschachtelte Media Queries — das ist großartig */
  @media (width >= 768px) {
    flex-direction: row;
  }
}

In frühen Implementierungen brauchtest du & vor Element-Selektoren (wie & p statt einfach p). Diese Einschränkung wurde aufgehoben. Stand 2025 unterstützen alle großen Browser blanke Element-Verschachtelung: .parent { p { ... } } funktioniert einwandfrei.

Verschachtelte Media Queries#

Das ist das Killer-Feature von CSS Nesting, meiner Meinung nach. Nicht die Selektor-Verschachtelung — die Möglichkeit, Media Queries innerhalb eines Regel-Blocks zu platzieren:

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

Vergleiche das mit dem alten Weg, wo deine .hero-Styles über drei verschiedene @media-Blöcke verstreut waren, möglicherweise hunderte Zeilen voneinander entfernt. Nesting hält das responsive Verhalten zusammen mit der Komponente. Die Lesbarkeit verbessert sich dramatisch.

Nicht zu tief verschachteln#

Eine Warnung: Nur weil du sechs Ebenen tief verschachteln kannst, heißt das nicht, dass du es solltest. Derselbe Ratschlag wie von Sass gilt hier. Wenn deine Verschachtelung Selektoren wie .page .section .card .content .text .highlight erzeugt, hast du ein Spezifitätsmonster und einen Wartungsalptraum geschaffen. Zwei oder drei Ebenen sind der Sweet Spot.

css
/* Gut — zwei Ebenen */
.nav {
  .link {
    color: inherit;
 
    &:hover {
      color: oklch(55% 0.2 260);
    }
  }
}
 
/* Schlecht — Spezifitätsalptraum */
.page {
  .layout {
    .sidebar {
      .nav {
        .list {
          .item {
            .link {
              color: red; /* viel Glück beim Überschreiben */
            }
          }
        }
      }
    }
  }
}

Farbfunktionen: oklch() und color-mix() ändern alles#

hsl() hatte einen guten Lauf. Es war intuitiver als rgb(). Aber es hat einen fundamentalen Fehler: es ist nicht wahrnehmungsmäßig gleichmäßig. Ein hsl(60, 100%, 50%) (Gelb) sieht für das menschliche Auge dramatisch heller aus als hsl(240, 100%, 50%) (Blau), obwohl sie denselben Helligkeitswert haben.

Warum oklch() gewinnt#

oklch() ist wahrnehmungsmäßig gleichmäßig. Gleiche Helligkeitswerte sehen gleich hell aus. Das ist enorm wichtig beim Generieren von Farbpaletten, beim Erstellen von Themes und beim Sicherstellen von barrierefreiem Kontrast:

css
:root {
  /* oklch(Helligkeit Chroma Farbton) */
  --color-primary: oklch(55% 0.2 260);     /* Blau */
  --color-secondary: oklch(55% 0.2 330);   /* Lila */
  --color-success: oklch(55% 0.2 145);     /* Grün */
  --color-danger: oklch(55% 0.25 25);      /* Rot */
  --color-warning: oklch(75% 0.18 85);     /* Gelb — höheres L für gleiche Wahrnehmung */
 
  /* Diese sehen alle gleich „mittel" für das menschliche Auge aus */
  /* Mit HSL bräuchtest du verschiedene Helligkeitswerte für jeden Farbton */
}

Die drei Werte sind intuitiv, sobald du sie verstehst:

  • Helligkeit (0% bis 100%): Wie hell oder dunkel. 0% ist Schwarz, 100% ist Weiß.
  • Chroma (0 bis ~0,37): Wie lebhaft. 0 ist Grau, höher ist gesättigter.
  • Farbton (0 bis 360): Der Farbwinkel. 0/360 ist rosa-rot, 145 ist Grün, 260 ist Blau.

color-mix() für abgeleitete Farben#

color-mix() lässt dich Farben aus anderen Farben zur Laufzeit erstellen. Keine Sass-darken()-Funktion. Kein JavaScript. Einfach CSS:

css
:root {
  --brand: oklch(55% 0.2 260);
 
  /* Aufhellen durch Mischen mit Weiß */
  --brand-light: color-mix(in oklch, var(--brand) 30%, white);
 
  /* Abdunkeln durch Mischen mit Schwarz */
  --brand-dark: color-mix(in oklch, var(--brand) 70%, black);
 
  /* Einen dezenten Hintergrund erstellen */
  --brand-bg: color-mix(in oklch, var(--brand) 8%, white);
 
  /* Halbtransparente 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);
  }
}

Das in oklch ist wichtig. Mischen in srgb ergibt matschige Zwischenfarben. Mischen in oklch ergibt wahrnehmungsmäßig gleichmäßige Ergebnisse. Mische immer in oklch.

Eine komplette Palette mit oklch() erstellen#

So generiere ich eine gesamte Abstufungspalette aus einem einzelnen Farbton:

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

Ändere --hue auf 145 und du hast eine grüne Palette. Ändere es auf 25 und du hast Rot. Die Helligkeitsstufen sind wahrnehmungsmäßig gleichmäßig. Das Chroma verjüngt sich an den Extremen, damit die hellsten und dunkelsten Abstufungen nicht übersättigt sind. Das ist die Art von Sache, die früher ein Design-Tool oder eine Sass-Funktion erforderte. Jetzt sind es acht Zeilen CSS.

Scroll-Driven Animations: Kein JavaScript nötig#

Das ist das Feature, das mich am meisten JavaScript hat löschen lassen. Scroll-verknüpfte Animationen — Fortschrittsbalken, Parallax-Effekte, Reveal-Animationen, Sticky Headers mit Übergängen — brauchten früher IntersectionObserver, scroll-Event-Listener oder eine Bibliothek wie GSAP. Jetzt ist es CSS.

Scroll-Fortschrittsindikator#

Der klassische „Lesefortschrittsbalken" oben auf einer Artikelseite:

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

Das ist alles. Der gesamte Lesefortschrittsindikator. Kein JavaScript. Kein Scroll-Event-Listener. Kein requestAnimationFrame. Keine „Prozent gescrollt"-Berechnung. Das animation-timeline: scroll()-Binding erledigt alles.

Reveal beim Scrollen#

Elemente, die einblenden, wenn sie in den Viewport eintreten:

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() bindet die Animation an die Sichtbarkeit des Elements im Viewport. animation-range: entry 0% entry 100% bedeutet, die Animation läuft von dem Moment, in dem das Element beginnt in den Viewport einzutreten, bis es vollständig sichtbar ist.

Parallax ohne Bibliothek#

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

Das Hintergrundbild bewegt sich mit einer anderen Rate als das Scrollen und erzeugt einen Parallax-Effekt. Flüssig, performant (der Browser kann auf der GPU kompositen) und null JavaScript.

Benannte Scroll-Timelines#

Für mehr Kontrolle kannst du Scroll-Timelines benennen und von anderen Elementen referenzieren:

css
.scroller {
  overflow-y: auto;
  scroll-timeline-name: --my-scroller;
  scroll-timeline-axis: block;
}
 
/* Ein Element irgendwo im DOM kann diese Timeline referenzieren */
.indicator {
  animation: progress linear both;
  animation-timeline: --my-scroller;
}
 
@keyframes progress {
  from { width: 0%; }
  to { width: 100%; }
}

Das funktioniert auch wenn der Indikator und der Scroller nicht Eltern/Kind sind. Jedes Element kann sich mit jeder benannten Scroll-Timeline verknüpfen. Das ist mächtig für Dashboard-UIs, wo ein scrollbarer Bereich einen Indikator in einem festen Header antreibt.

Anchor Positioning: Tooltips und Popovers richtig gemacht#

Vor Anchor Positioning erforderte das Verbinden eines Tooltips mit seinem Trigger-Element JavaScript. Du musstest Positionen mit getBoundingClientRect() berechnen, Scroll-Offsets handhaben, Viewport-Kollisionen managen und bei Resize neu berechnen. Bibliotheken wie Popper.js (jetzt Floating UI) existierten speziell, weil das so schwer richtig zu machen war.

CSS Anchor Positioning macht das deklarativ:

css
.trigger {
  anchor-name: --my-trigger;
}
 
.tooltip {
  position: fixed;
  position-anchor: --my-trigger;
 
  /* Tooltip oben-Mitte am unteren-Mitte des Triggers positionieren */
  top: anchor(bottom);
  left: anchor(center);
  translate: -50% 0;
 
  /* Fallback-Positionierung wenn es über den Viewport hinausgeht */
  position-try-fallbacks: flip-block, flip-inline;
}

Automatische Viewport-Kollisionsbehandlung#

Die position-try-fallbacks-Eigenschaft ist der Teil, der 200 Zeilen JavaScript gebraucht hätte. Sie sagt dem Browser: „Wenn der Tooltip unten über den Viewport hinausgeht, flippe ihn nach oben. Wenn er rechts hinausgeht, flippe ihn nach links."

css
.dropdown-menu {
  position: fixed;
  position-anchor: --menu-button;
 
  /* Standard: unter dem Button, am linken Rand ausgerichtet */
  top: anchor(bottom);
  left: anchor(left);
 
  /* Wenn es unten nicht passt, versuche oben. Wenn links-ausgerichtet nicht passt, versuche rechts-ausgerichtet */
  position-try-fallbacks: flip-block, flip-inline;
 
  /* Abstand zwischen Anchor und Dropdown */
  margin-top: 4px;
}

Benannte Positions-Fallbacks#

Für mehr Kontrolle über Fallback-Positionen kannst du eigene Try-Optionen 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;
}

Der Browser probiert jeden Fallback der Reihe nach, bis er einen findet, der das Element im Viewport hält. Das ist die Art von räumlichem Denken, das in JavaScript wirklich schmerzhaft war.

Anchoring mit der Popover API#

Anchor Positioning passt perfekt zur neuen Popover API:

html
<button popovertarget="my-popover" style="anchor-name: --btn">Einstellungen</button>
 
<div popover id="my-popover" style="
  position-anchor: --btn;
  top: anchor(bottom);
  left: anchor(center);
  translate: -50% 0;
  margin-top: 8px;
">
  Popover-Inhalt hier
</div>

Kein JavaScript zum Anzeigen/Ausblenden (die Popover API handhabt das). Kein JavaScript zum Positionieren (Anchor Positioning handhabt das). Kein JavaScript für Light-Dismiss-Verhalten (die Popover API handhabt das auch). Das gesamte Tooltip/Popover/Dropdown-Muster — ein Muster, das ganze npm-Pakete antrieb — ist jetzt HTML und CSS.

CSS Grid Subgrid: Verschachtelte Ausrichtung, die wirklich funktioniert#

Grid ist mächtig, aber es hatte eine frustrierende Einschränkung: ein Kind-Grid konnte seine Elemente nicht am Eltern-Grid ausrichten. Wenn du eine Reihe von Cards hattest und wolltest, dass Titel, Inhalt und Footer jeder Card über Cards hinweg ausgerichtet sind, hattest du Pech. Das interne Grid jeder Card war unabhängig.

Subgrid behebt das.

Das Card-Ausrichtungsproblem#

css
/* Eltern-Grid */
.card-grid {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 1.5rem;
}
 
/* Jede Card wird ein Subgrid, das Zeilen am Eltern ausrichtet */
.card {
  display: grid;
  grid-row: span 3; /* Card überspannt 3 Zeilen im Eltern */
  grid-template-rows: subgrid;
  gap: 0; /* Card kontrolliert ihren eigenen internen Abstand */
}
 
.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);
}

Jetzt richten sich die Titel über alle drei Cards auf derselben Zeile aus. Die Bodys richten sich aus. Die Footers richten sich aus. Selbst wenn eine Card einen zweizeiligen Titel hat und eine andere einen einzeiligen, wird die Ausrichtung durch das Eltern-Grid beibehalten.

Vor Subgrid: Die Hacks#

Ohne Subgrid erforderte diese Ausrichtung entweder:

  1. Feste Höhen (brüchig, bricht bei dynamischem Inhalt)
  2. JavaScript-Messung (langsam, Flash of Misalignment)
  3. Aufgeben und Fehlausrichtung akzeptieren (häufig, hässlich)
css
/* Der alte Hack — brüchig und bricht bei dynamischem Inhalt */
.card-title {
  min-height: 3rem; /* beten, dass kein Titel das übersteigt */
}
 
.card-body {
  min-height: 8rem; /* noch mehr beten */
}

Subgrid macht den Hack unnötig. Das Eltern-Grid verteilt Zeilenhöhen basierend auf dem höchsten Inhalt in jeder Zeile über alle Cards.

Spalten-Subgrid für Formularlayouts#

Subgrid funktioniert auch auf Spalten, was perfekt für Formularlayouts ist:

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 richten sich aus. Alle Inputs richten sich aus. Die Label-Spalte passt sich automatisch an das breiteste Label an. Keine hartcodierten Breiten.

View Transitions API: SPA-ähnliche Navigation ohne Framework#

Das ist das ambitionierteste Feature auf dieser Liste. Die View Transitions API ermöglicht es, zwischen Seitennavigationen zu animieren — einschließlich dokumentenübergreifender Navigationen (normale Linkklicks in einer mehrseitigen App). Deine statische HTML-Seite kann jetzt flüssige, animierte Seitenübergänge haben.

Dokumentenübergreifende Transitions#

Um dokumentenübergreifende View Transitions zu aktivieren, fügst du eine einzige CSS-Regel sowohl auf der alten als auch der neuen Seite hinzu:

css
@view-transition {
  navigation: auto;
}

Das ist alles. Der Browser wird jetzt zwischen Seiten beim Navigieren überblenden. Die Standard-Transition ist ein sanftes Opacity-Fade. Kein JavaScript. Kein Framework. Nur zwei Zeilen CSS auf jeder Seite.

Die Transition anpassen#

Du kannst anpassen, welche Transitions passieren, indem du bestimmte Elemente benennst:

css
/* Auf beiden Seiten */
.page-header {
  view-transition-name: header;
}
 
.main-content {
  view-transition-name: content;
}
 
.hero-image {
  view-transition-name: hero;
}
 
/* Transition-Animationen */
::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; /* Das neue Hero erscheint einfach */
}
 
@keyframes slide-out {
  to {
    transform: translateX(-100%);
    opacity: 0;
  }
}
 
@keyframes slide-in {
  from {
    transform: translateX(100%);
    opacity: 0;
  }
}
 
@keyframes fade-out {
  to {
    opacity: 0;
  }
}

Wenn du zwischen zwei Seiten navigierst, die beide ein .hero-image mit view-transition-name: hero haben, animiert der Browser das Bild automatisch von seiner Position auf der alten Seite zu seiner Position auf der neuen Seite. Das ist das „Shared Element Transition"-Muster aus der Mobile-Entwicklung, jetzt im Browser.

SPA View Transitions#

Für Single-Page Apps (React, Vue, Svelte, etc.) ist die JavaScript API unkompliziert:

css
/* CSS-Seite — definiere deine Transition-Animationen */
::view-transition-old(root) {
  animation: fade-out 0.2s ease-in;
}
 
::view-transition-new(root) {
  animation: fade-in 0.2s ease-out;
}
javascript
// JS-Seite — wickle dein DOM-Update in startViewTransition
document.startViewTransition(() => {
  // DOM hier aktualisieren — React Render, innerHTML Swap, etc.
  updateContent(newPageData);
});

Der Browser macht einen Snapshot des alten Zustands, führt dein Update aus, macht einen Snapshot des neuen Zustands und animiert zwischen ihnen. Benannte Elemente bekommen individuelle Transitions; alles andere bekommt ein Standard-Crossfade.

Nutzereinstellungen respektieren#

Respektiere immer prefers-reduced-motion:

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

Was du nicht mehr verwenden musst#

Diese Features zusammen eliminieren eine Menge Tooling, Bibliotheken und Muster, auf die wir uns jahrelang verlassen haben. Hier ist, was ich entfernt oder nicht mehr verwendet habe:

Sass/SCSS für Nesting und Variablen#

CSS hat natives Nesting. CSS hat Custom Properties (und hat sie seit Jahren). Die zwei Hauptgründe, warum Leute nach Sass griffen, sind jetzt in der Sprache. Wenn du Sass nur noch für $variables und Nesting verwendest, kannst du aufhören.

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

Die CSS-Version ist mächtiger — oklch bietet wahrnehmungsmäßig gleichmäßige Farbmanipulation, color-mix funktioniert zur Laufzeit (Sass' lighten ist nur zur Compile-Zeit), und Custom Properties können dynamisch von JavaScript oder Media Queries geändert werden.

JavaScript für Scroll-Animationen#

Lösche deinen IntersectionObserver-Reveal-on-Scroll-Code. Lösche dein Scroll-Fortschrittsbalken-JavaScript. Lösche deinen Parallax-Scroll-Handler. animation-timeline: scroll() und animation-timeline: view() handhaben all das mit besserer Performance (Compositor-Thread, nicht Main Thread).

javascript
// Vorher: JavaScript, das du löschen kannst
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
/* Nachher: CSS, das all das oben ersetzt */
.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 für Positionierung#

Wenn du Floating UI (früher Popper.js) nur für grundlegendes Tooltip/Popover-Positioning verwendest, ersetzt CSS Anchor Positioning es. Du verlierst einige der erweiterten Features (virtuelle Elemente, Custom Middleware), aber für den 90%-Anwendungsfall — „positioniere dieses Popover neben diesem Button und halte es im Viewport" — macht CSS das jetzt nativ.

margin: auto Zentrierungs-Hacks#

Das ist nicht neu, aber ich sehe immer noch überall margin: 0 auto. Moderne Layout-Tools machen es in den meisten Fällen unnötig:

css
/* Alter Weg */
.container {
  max-width: 1200px;
  margin: 0 auto;
  padding: 0 1rem;
}
 
/* Besser: CSS Grid oder Flexbox */
.page {
  display: grid;
  place-items: center;
}
 
/* Oder: Container Queries machen den Container selbst responsiv */
.content {
  container-type: inline-size;
  width: min(100% - 2rem, 1200px);
  margin-inline: auto; /* OK, immer noch auto, aber margin-inline ist klarer */
}

margin: 0 auto funktioniert immer noch einwandfrei. Aber wenn du es für vertikale Zentrierung oder komplexe Ausrichtung verwendest, greife stattdessen zu Flexbox oder Grid.

Media Queries für Komponenten-Level-Responsiveness#

Das ist der große Punkt. Wenn du @media-Queries schreibst, um eine Komponente responsiv zu machen, machst du es wahrscheinlich jetzt falsch. Container Queries sollten dein Standard für Komponenten-Level-Responsiveness sein. Reserviere @media-Queries für seitenebene Entscheidungen: Layout-Änderungen, Navigationsmuster, Print-Styles.

css
/* Vorher: Media Query für Komponenten-Styling (falscher Scope) */
@media (min-width: 768px) {
  .product-card {
    flex-direction: row;
  }
}
 
/* Nachher: Container Query (richtiger Scope) */
.product-card-wrapper {
  container-type: inline-size;
}
 
@container (min-width: 400px) {
  .product-card {
    flex-direction: row;
  }
}

Das große Bild#

Diese Features existieren nicht isoliert. Sie komponieren. Container Queries + :has() + Nesting + oklch() + Layers — zusammen verwendet — geben dir ein CSS-Authoring-Erlebnis, das vor fünf Jahren nicht wiederzuerkennen gewesen wäre:

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

Dieser einzelne Block handhabt:

  • Komponenten-Level responsives Layout (Container Queries)
  • Bedingtes Styling basierend auf Inhalt (:has())
  • Saubere Selektor-Organisation (Nesting)
  • Wahrnehmungsmäßig gleichmäßige Farben (oklch)
  • Vorhersehbares Spezifitätsmanagement (@layer)

Kein Präprozessor. Keine CSS-in-JS-Runtime. Keine Utility-Klassen dafür. Einfach CSS, das tut, was CSS schon immer hätte tun sollen.

Die Plattform hat aufgeholt. Endlich. Und es war das Warten wert.

Ähnliche Beiträge