CSS @layer in der Praxis: Kaskadenebenen statt Spezifitätskrieg

Cascade Layers von Grund auf: Syntax, Kaskaden-Reihenfolge, Frameworks wie Bootstrap und Tailwind im eigenen Layer, Shadow DOM und Design-Tokens.

von ·

Es gibt eine Zeile CSS, die fast jeder schon geschrieben hat und danach bereut:

.sidebar .widget ul li a.link { color: #fff !important; }

Diese Zeile ist kein Styling. Sie ist eine Kapitulation. Jemand wollte eine Farbe ändern, ist an einem Selektor gescheitert, hat einen weiteren Klassennamen davorgesetzt, ist wieder gescheitert, und am Ende kam das !important dazu. Das Ergebnis funktioniert — bis jemand anderes dasselbe Spiel eine Ebene höher spielt.

Genau dafür gibt es seit März 2022 in allen Browser-Engines eine Lösung: Cascade Layers, die At-Rule @layer. At-Rules sind CSS-Anweisungen, die mit einem @ beginnen — @media, @import, @supports; sie enden entweder mit Semikolon oder umschließen einen eigenen Block. Diese hier fügt der CSS-Kaskade eine Ebene hinzu, die über der Spezifität liegt. (Die Kaskade ist der Algorithmus, mit dem der Browser bei konkurrierenden Angaben für dieselbe Eigenschaft einen Gewinner bestimmt — nicht zu verwechseln mit der Vererbung.) Statt Selektoren aufzublähen, sagst du einmal, in welcher Reihenfolge deine Stil-Schichten gelten sollen — und danach gewinnt die spätere Schicht, egal wie schwach ihr Selektor ist.

Dieser Artikel ist die ausführliche Tour: die Regeln (jede davon im Browser nachgemessen, nicht aus der Doku abgeschrieben), die Umstellung einer real gewachsenen Website, Frameworks wie Bootstrap und Tailwind in eigenen Layern, das Verhältnis zu Web Components und Shadow DOM — und ein Muster, mit dem Design-Tokens ihre Layer-Zuordnung selbst mitbringen.

Methodik-Hinweis: Alle Verhaltensbehauptungen in diesem Artikel wurden gegen einen echten Browser geprüft (Chromium-Engine, Edge 151, headless, getComputedStyle). Wo Dokumentation und Spezifikation sich widersprechen, steht hier das gemessene Ergebnis — an einer Stelle weiter unten ist genau das passiert.

Das Problem: Spezifität ist ein schlechtes Ordnungssystem

Bevor @layer existierte, hattest du in CSS genau drei Hebel, um zu entscheiden, welche von zwei konkurrierenden Deklarationen gewinnt. Deklaration meint dabei genau ein eigenschaft: wert;-Paar; eine Regel ist der Selektor plus der geschweifte Block, der eine oder mehrere Deklarationen enthält. Die drei Hebel:

  1. Herkunft und Wichtigkeit — Browser-Stylesheet, Nutzer-Stylesheet, Autoren-Stylesheet, plus !important.
  2. Spezifität — die berühmte Zählung von ID-, Klassen- und Element-Selektoren. Der Browser zählt je Selektor drei Stellen: IDs — Klassen, Attribute und Pseudo-Klassen — Elemente und Pseudo-Elemente. #hinweis ergibt 1-0-0, .box.box.box ergibt 0-3-0, div.box ergibt 0-1-1. Verglichen wird von links nach rechts, der höhere Wert gewinnt.
  3. Reihenfolge im Quelltext — bei Gleichstand gewinnt die letzte Deklaration.

Alle drei sind für kleine Stylesheets völlig ausreichend und für große Projekte ungeeignet. Der Grund ist strukturell: Spezifität ist eine Eigenschaft des Selektors, nicht der Absicht. Du willst sagen „diese Regel gehört zu meinem Design-System und darf von Komponenten überschrieben werden". Was du stattdessen sagen kannst, ist „diese Regel hat zwei Klassen und ein Element". Das sind zwei völlig verschiedene Aussagen, und CSS zwingt dich, die zweite zu benutzen, um die erste zu meinen.

Daraus folgt der bekannte Verlauf jedes größeren Projekts:

  • Zuerst werden Selektoren länger, um zu gewinnen.
  • Dann kommt !important dazu, weil Selektoren nicht mehr reichen.
  • Dann kommt !important in der Gegenrichtung, weil das erste !important im Weg ist.
  • Am Ende traut sich niemand mehr, irgendetwas zu löschen.

Methodiken wie BEM (Block Element Modifier), SMACSS (Scalable and Modular Architecture for CSS) oder ITCSS (Inverted Triangle CSS) sind allesamt Versuche, dieses Problem mit Disziplin zu lösen — über Konventionen für Namensgebung und Datei-Reihenfolge: Halte die Spezifität flach, sortiere die Datei so, dass Allgemeines zuerst und Wichtiges unten steht. Das funktioniert, solange sich alle daran halten — und bricht in dem Moment, in dem Fremd-CSS ins Spiel kommt, das sich naturgemäß nicht an deine Konvention hält.

Cascade Layers lösen das Problem an der richtigen Stelle: Sie machen die Absicht zu einem erstklassigen Bestandteil der Kaskade.

Was Cascade Layers eigentlich sind

Ein Cascade Layer ist ein benannter Eimer für CSS-Regeln. Du legst einmal die Reihenfolge der Eimer fest, danach gilt:

Bei konkurrierenden normalen Deklarationen gewinnt der Eimer, der später deklariert wurde — unabhängig von der Spezifität der Selektoren darin.

Das ist die ganze Grundidee. Alles Weitere sind Details und Sonderfälle.

Das Minimalbeispiel:

@layer basis, komponenten;

@layer basis {
	#hinweis { color: red; }        /* Spezifität 1-0-0 */
}

@layer komponenten {
	.hinweis { color: green; }      /* Spezifität 0-1-0 */
}

Das Element wird grün. Der ID-Selektor verliert gegen einen Klassen-Selektor, weil komponenten nach basis deklariert wurde. In klassischem CSS wäre das schlicht unmöglich gewesen.

Die Kaskade in voller Länge

Um @layer sicher einzusetzen, muss man wissen, wo genau Layer in der Kaskade sitzen. Die Spezifikation (CSS Cascading and Inheritance Level 5, englisch) sortiert konkurrierende Deklarationen in dieser Reihenfolge — der erste Punkt, der einen Unterschied macht, entscheidet:

  1. Herkunft und Wichtigkeit (Browser / Nutzer / Autor, jeweils normal und !important)
  2. Kapselungskontext (Shadow-DOM-Baum — dazu weiter unten ein eigenes Kapitel)
  3. Element-gebundene Styles (das style-Attribut)
  4. Layer
  5. Spezifität
  6. Reihenfolge im Quelltext

Zwei Dinge daran sind praktisch wichtig und werden oft übersehen:

Layer stehen über Spezifität, aber unter Inline-Styles. Ein style="color: red" im HTML schlägt jeden Layer. @layer ersetzt also nicht die Regel „keine Inline-Styles im Markup".

Der Kapselungskontext steht noch über den Layern. Das ist der Grund, warum @layer und Shadow DOM sich anders verhalten, als man zunächst annimmt — das Kapitel dazu kommt später.

Und die vollständige Rangfolge im Autoren-Bereich der Kaskade, von schwach nach stark — die beiden Animations-Ränge sind streng genommen eigene Herkunftsbereiche, die hier hineinragen:

Rang Was
1 (schwächster) Erster Layer, normal
2 Zweiter Layer, normal
weitere Layer, normal
3 Ungelayerte Regeln, normal
4 Inline-Styles, normal
5 Laufende CSS-Animationen
6 Ungelayerte Regeln, !important
weitere Layer, !importantrückwärts
7 Zweiter Layer, !important
8 Erster Layer, !important
9 Inline-Styles, !important
10 (stärkster) Laufende CSS-Transitions

Die beiden Zeilen, die für Fehler sorgen, sind Rang 3 und Rang 6 — dazu gleich mehr.

Die Syntax: fünf Formen, mehr gibt es nicht

1. Die Reihenfolge-Deklaration

@layer reset, base, components, utilities;

Diese Zeile erzeugt vier leere Layer in genau dieser Reihenfolge. Sie sollte ganz oben in deinem Stylesheet stehen — sie ist der Vertrag, den der Rest der Datei erfüllt. Wo die Regeln später tatsächlich stehen, ist danach egal.

Das ist der wichtigste Trick an der ganzen At-Rule: Die Reihenfolge wird durch die erste Erwähnung eines Layer-Namens festgelegt, nicht durch die Position der Regeln. Du kannst deine Datei also weiter so sortieren, wie es für Menschen lesbar ist.

2. Der Layer-Block

@layer components {
	.btn {
		border-radius: 0.4rem;
		padding: 0.5em 1em;
	}
}

Du darfst denselben Layer beliebig oft öffnen. Der Browser führt alle Blöcke desselben Namens zu einem Layer zusammen; innerhalb des Layers gelten dann wieder Spezifität und Quelltext-Reihenfolge.

@layer components { .btn { padding: 0.5em 1em; } }
@layer utilities  { .p-0 { padding: 0; } }
@layer components { .card { padding: 1rem; } }   /* landet trotzdem vor utilities */

Das ist keine Schlamperei, sondern die vorgesehene Arbeitsweise für gewachsene Dateien — genau so ist auch die weiter unten beschriebene Umstellung von jpkc.com gelaufen.

3. Anonyme Layer

@layer {
	/* Regeln ohne Layer-Namen */
}

Ein Block ohne Namen erzeugt einen Layer, den niemand mehr referenzieren, erweitern oder umsortieren kann. Jedes weitere @layer { } erzeugt einen neuen anonymen Layer. Nützlich für Code, den du bewusst abschotten willst; ungeeignet für alles, was du später noch anfassen musst. (Zu überschreiben ist ein anonymer Layer dagegen trivial — jeder spätere Layer und jede ungelayerte Regel schlägt ihn.)

4. Import in einen Layer

@import url("bootstrap.css") layer(framework);
@import url("legacy.css") layer;            /* anonymer Layer */

Achte auf die zweite Zeile: Für den anonymen Layer steht das Schlüsselwort ohne Klammern. layer() mit leeren Klammern sieht plausibel aus, ist aber kein gültiger Wert — und das Fatale daran ist die Fehlerbehandlung: Der gesamte @import wird ungültig, das Stylesheet lädt gar nicht, und zwar ohne Konsolenfehler. Gemessen: Die importierten Regeln fehlen ersatzlos.

So bekommst du fremde Stylesheets in einen Layer, ohne ihren Inhalt anzufassen. Das ist der Kern des Framework-Kapitels weiter unten.

Wichtig zur Reihenfolge im Dokument: @import muss vor allen anderen Regeln stehen — mit genau zwei Ausnahmen, @charset und @layer-Reihenfolgezeilen. Gemeint ist dabei ausschließlich die Statement-Form @layer a, b;. Ein @layer name { … }-Block davor macht jedes folgende @import ungültig; die Spezifikation hält ausdrücklich fest, dass Layer-Blöcke nicht zwischen @import-Regeln stehen dürfen. Das folgende ist also gültig und auch die empfohlene Schreibweise:

@layer framework, app;                          /* zuerst die Reihenfolge festnageln */
@import url("bootstrap.css") layer(framework);  /* dann importieren */

5. Verschachtelte Layer

@layer components {
	@layer card { /* … */ }
	@layer button { /* … */ }
}

/* gleichbedeutend, mit Punktnotation: */
@layer components.card { /* … */ }
@layer components.button { /* … */ }

Verschachtelte Layer erzeugen eine Unter-Rangfolge innerhalb ihres Elternteils. Der Name ist dabei an den Elternteil gebunden: components.card und utilities.card sind zwei völlig verschiedene Layer.

Der Sinn ist Kapselung von Namensräumen. Ein Framework kann intern framework.reset, framework.base, framework.components benutzen, ohne dass sich diese Namen mit deinen eigenen reset/base/components beißen.

Acht Regeln, die du kennen musst

Die folgenden acht Punkte sind das, was in der Praxis tatsächlich zu Überraschungen führt. Jeder ist unten mit dem gemessenen Ergebnis aus einem echten Browser belegt.

Regel 1: Layer schlägt Spezifität — immer

@layer a, b;
@layer b { #box { color: blue; } }                 /* 1-0-0 */
@layer a { .box.box.box { color: red; } }          /* 0-3-0 */

Gemessen: blau. Innerhalb eines Layers zählt Spezifität weiter ganz normal. Zwischen Layern zählt sie überhaupt nicht.

Das ist die Eigenschaft, wegen der man @layer einsetzt: Du kannst in einem späteren Layer mit einem einzigen Klassen-Selektor etwas überschreiben, das im früheren Layer mit einem ID-Selektor gesetzt wurde.

Regel 2: Die Reihenfolge steht bei der ersten Erwähnung fest

@layer a { #box { color: red; } }
@layer b { #box { color: green; } }
@layer b, a;                                       /* zu spät */

Gemessen: grün. Die Layer wurden bereits durch die beiden Blöcke in der Reihenfolge a, b angelegt. Die nachträgliche Deklarationszeile ändert nichts mehr — sie erwähnt nur zwei Layer, die es schon gibt.

Praktische Konsequenz: Eine @layer-Reihenfolgezeile ist nur dann wirksam, wenn sie vor allen betroffenen Blöcken steht. Deshalb gehört sie in die erste Zeile deines Einstiegs-Stylesheets — und in genau eine Datei, nicht in mehrere.

Regel 3: Ungelayerte Styles gewinnen gegen alle Layer

@layer alles { #box { color: blue; } }             /* 1-0-0, im Layer */
div.box { color: green; }                          /* 0-1-1, ohne Layer */

Gemessen: grün. Ungelayerte Regeln verhalten sich, als lägen sie in einem impliziten letzten Layer — und der letzte gewinnt.

Das ist gleichzeitig sehr praktisch und sehr gefährlich:

  • Praktisch, weil du in einer Bestandscodebasis einfach anfangen kannst, Teile in Layer zu ziehen, ohne dass der noch ungelayerte Rest plötzlich schwächer wird. Migration ohne Big Bang.
  • Gefährlich, weil jede Datei, die du vergisst zu layern, automatisch die höchste Priorität bekommt. Ein einziges vergessenes <link> auf ein Fremd-Stylesheet hebelt deine ganze Layer-Architektur aus. Dazu unten mehr.

Regel 4: !important dreht die Reihenfolge um

Das ist die Regel, die am häufigsten für Verwirrung sorgt, und sie ist völlig konsistent gedacht: !important bedeutet in CSS seit jeher „Umkehrung der normalen Machtverhältnisse" — deshalb schlagen !important-Regeln des Nutzers die des Autors. Bei Layern gilt dasselbe Prinzip.

@layer a, b;
@layer a { #box { color: red   !important; } }
@layer b { #box { color: blue  !important; } }

Gemessen: rot. Bei !important gewinnt der frühere Layer.

Und die Fortsetzung davon:

@layer irgendwas { #box { color: blue !important; } }
#box { color: red !important; }                    /* ungelayert */

Gemessen: blau. Ungelayerte !important-Regeln sind unter allen Autoren-!important-Regeln die schwächsten — spiegelbildlich zu Regel 3.

Merke: Ein Layer, den du weit vorne einsortierst, ist bei normalen Deklarationen der schwächste — und bei !important der stärkste. Diese Doppelrolle ist der Hebel für das Bootstrap-Rezept weiter unten.

Regel 5: Verschachtelte Layer verhalten sich wie ihr Elternteil im Kleinen

@layer c.card   { #box { color: red;   } }
@layer c.button { #box { color: green; } }

Gemessen: grün. Innerhalb von c gilt dieselbe Logik: später deklariert gewinnt.

Und der Sonderfall, den man einmal gesehen haben muss:

@layer c;
@layer c.child { #box { color: red;   } }
@layer c       { #box { color: green; } }   /* direkt in c, nicht in einem Kind */

Gemessen: grün. Regeln, die direkt im Elternteil liegen, gelten als impliziter letzter Unter-Layer — sie schlagen also alle benannten Kinder. Mit !important kehrt sich auch das um (dann gewinnt das erste Kind).

Regel 6: revert-layer gibt Kontrolle an die vorherige Schicht zurück

@layer basis, thema;
@layer basis { .btn { color: green; } }
@layer thema { .btn { color: red; }
               .btn.plain { color: revert-layer; } }

Gemessen: grün für .btn.plain. Der Wert wird nicht auf den Initialwert zurückgesetzt (das wäre unset beziehungsweise initial), sondern auf den, den die vorherige Schicht gesetzt hätte.

Zwei Randfälle, beide gemessen:

  • revert-layer im untersten Layer fällt durch bis ins Browser-Stylesheet. Ein <a href> mit color: revert-layer im einzigen Layer wurde zu rgb(0, 0, 238) — dem Standard-Linkblau des Browsers.

  • revert-layer in ungelayertem CSS fällt auf die Schicht darunter zurück — also auf den letzten expliziten Layer, der die Eigenschaft überhaupt setzt, nicht ins Browser-Stylesheet. Getestet mit zwei Layern l1 (rot) und l2 (grün) plus einer ungelayerten Regel color: revert-layer — Ergebnis: grün, also der Wert aus l2. Setzt l2 die Eigenschaft nicht, fällt es weiter auf l1 durch; setzt sie kein Layer, landet man doch beim Browser-Stylesheet.

    Der zweite Punkt ist bemerkenswert, weil verbreitete Zusammenfassungen hier etwas anderes behaupten (nämlich pauschal Rückfall auf das Browser-Stylesheet). Das gemessene Verhalten deckt sich mit der Spezifikation: Ungelayertes CSS ist der implizite letzte Layer, und „vorheriger Layer" ist entsprechend der letzte benannte. Es ist auch kein Chromium-Sonderweg — die Web-Platform-Tests decken genau diesen Fall ab (css/css-cascade/revert-layer-002.html), und er besteht in Chrome, Firefox und Safari.

Und eine Grenze: revert-layer ist eine normale Deklaration. Gegen ein !important aus einem anderen Layer kommt sie nicht an — auch das gemessen.

Regel 7: Ein Layer, der nur deklariert wurde, hält seinen Platz

@layer erst, zweit;
@layer zweit { #box { color: green; } }
@layer erst  { #box { color: red;   } }

Gemessen: grün. Die Deklarationszeile hat beide Layer angelegt; dass zweit textlich zuerst befüllt wird, spielt keine Rolle.

Ein Detail für Fortgeschrittene: Layer, die innerhalb einer @media-Bedingung angelegt werden, entstehen nur, wenn die Bedingung zutrifft. Damit hängt die Layer-Reihenfolge plötzlich vom Viewport ab. Das ist selten gewollt — deklariere die Reihenfolge unbedingt außerhalb aller Bedingungsblöcke.

Regel 8: Animationen und Transitions stehen über allem

@layer alles { #box { color: red; } }
#box { animation: farbe 100s step-end forwards; }
@keyframes farbe { from { color: green; } to { color: green; } }

Gemessen: grün. Eine laufende Animation schlägt jede normale Deklaration, gelayert oder nicht; eine laufende Transition schlägt sogar !important. Das ist kein Layer-Spezifikum, aber es erklärt einen Teil der Fälle, in denen @layer scheinbar „nicht wirkt".

Praxis 1: Eine gewachsene Website auf Layer umstellen

Der Fall, den ich am besten belegen kann, ist die Hauptseite jpkc.com. Deren CSS ist über Jahre gewachsen, hat einen Bootstrap-4-Erbteil im Reset und wird als eine einzige Datei inline in jede Seite geschrieben (rund 1.900 Zeilen, minifiziert per lightningcss). Ein klassischer Kandidat also: viel Bestand, keine Lust auf Neuschreiben.

Die Umstellung war ein einzelner Commit und bestand aus genau zwei Handgriffen.

Erstens die Reihenfolge festlegen, direkt nach den Custom Properties:

@layer reset, base, components, utilities, responsive;

Zweitens die bestehenden Abschnitte in Blöcke wickeln — ohne eine einzige Regel zu verschieben:

Layer Was hineinkam
reset html, body, :target, Scrollbars, die :focus-visible-Grundlinie
base Typografie, Links, Code, Medien, Tabellen, Formulare, ::selection
components Buttons, Dropdown, SVG-Icons, Header, Hauptbereich, Navigation, <details>, Footer
utilities .visually-hidden, Spacer, .box, Verzeichnisbaum-Liste, Light-Toggle
responsive sämtliche @media-Blöcke — Breakpoints, inverted-colors, Print

Die Datei behielt ihre Abschnittsreihenfolge. Weil sich mehrere Blöcke desselben Namens zusammenführen (Regel: Layer-Block), steht @layer components { … } an mehreren Stellen der Datei — das ist beabsichtigt und kein Fehler.

Drei Entscheidungen daran sind übertragbar:

Die Tokens bleiben ungelayert. Der :root-Block mit allen Custom Properties steht vor der Layer-Deklaration und außerhalb jedes Layers. (Custom Properties sind CSS-Variablen: mit --name: wert definiert, mit var(--name) gelesen; sie vererben sich an Nachfahren. :root ist die Pseudo-Klasse für das Wurzelelement, in HTML also <html>.) Damit haben die Design-Tokens unbedingte Priorität und können von keiner Komponente versehentlich überschrieben werden. Ob das die richtige Wahl ist, hängt vom Projekt ab — das Design-Token-Kapitel weiter unten diskutiert die Gegenposition.

responsive als eigener Layer ist der eigentliche Gewinn. Vorher galt: Ein @media-Block überschreibt eine Komponentenregel nur, wenn er im Quelltext weiter unten steht und mindestens dieselbe Spezifität hat. Beides musste man beim Bearbeiten im Kopf behalten. Jetzt gewinnt der Media-Block immer, weil sein Layer der letzte ist — auch mit einem simpleren Selektor. Genau das war zuvor die Fehlerquelle Nummer eins beim Anfassen alter Breakpoints.

Die !important-Umkehr wurde bewusst protokolliert. In der neuen Ordnung ist das !important in .visually-hidden (Layer utilities) stärker als jedes !important im Print-Stylesheet (Layer responsive). Aktuell gibt es keine Kollision, weil beide unterschiedliche Eigenschaften setzen — aber genau solche Konstellationen muss man beim Umstellen einmal durchsehen und notieren. Wer viele !important im Bestand hat, sollte die vor der Umstellung inventarisieren.

Und die Kosten? Exakt +83 Byte pro Seite nach der Minifizierung — auf jeder Seite dieselben 83 Byte, weil es schlicht die At-Rule-Zeilen sind. Bei einer Datei, die vollständig inline in jedes HTML-Dokument geht, sind das je nach Seitenlänge 0,06 % (Startseite) bis 0,18 % (kürzeste Unterseite). Für externe Stylesheets ist der Effekt durch Kompression noch kleiner. @layer kostet praktisch nichts.

Ein Detail zum Toolchain-Vertrauen: lightningcss erhält die Layer-Semantik beim Minifizieren, es entfernt die At-Rules nicht. Ein Minifier schreibt CSS auf minimale Größe um — benachbarte Regeln zusammenführen, Langform zu Kurzform verdichten —, und lightningcss ist so ein Werkzeug, geschrieben in Rust. Das ist nicht selbstverständlich und sollte bei jedem Build-Tool einmal am Output geprüft werden — ein Minifier, der @layer-Blöcke „wegoptimiert", kehrt die gesamte Kaskade um.

Das Migrations-Rezept in Kurzform

  1. @layer-Reihenfolge ganz oben deklarieren.
  2. Bestehende Abschnitte an Ort und Stelle einwickeln, nichts verschieben.
  3. Custom Properties bewusst platzieren (drinnen oder draußen — aber entschieden).
  4. Alle !important im Projekt auflisten und die Umkehr gegenprüfen.
  5. Bauen, minifizierten Output auf erhaltene @layer-Blöcke prüfen.
  6. Visuell diffen. Wenn die Reihenfolge korrekt gewählt ist, ändert sich nichts — genau das ist das Ziel.

Praxis 2: Bootstrap in einen eigenen Layer

Der eigentliche Star-Anwendungsfall von @layer ist Fremd-CSS. Nimm Bootstrap als Beispiel, weil es das verbreitetste ist und weil sein Verhalten sehr gut messbar ist.

Ich habe die aktuelle Distribution heruntergeladen und ausgezählt — Bootstrap 5.3.8, dist/css/bootstrap.css, rund 280 KB:

  • @layer-Vorkommen: 0. Bootstrap 5 liefert keine Cascade Layers aus. Alles landet ungelayert — und damit nach Regel 3 über allem, was du selbst in Layer packst.
  • !important-Vorkommen: 1.716. Der Löwenanteil kommt aus der Utility-API — einem Sass-Generator, der aus einer Konfigurations-Map Klassen wie .m-1 oder .text-danger erzeugt. Eine Utility-Klasse hat genau einen Zweck und wird im Markup mit anderen kombiniert; .mt-0 setzt margin-top: 0, .text-center zentriert den Text. Jede erzeugte Utility-Klasse trägt per Voreinstellung !important, damit sie ihre Komponente sicher überschreibt.

Diese beiden Zahlen beschreiben das Problem präzise. Wer Bootstrap anpassen will, kämpft nicht gegen dessen Spezifität, sondern gegen dessen Position in der Kaskade und gegen 1.716 !important.

Schritt 1: Das Framework in einen frühen Layer

@layer framework, app;

@import url("bootstrap.css") layer(framework);

@layer app {
	.btn {
		border-radius: 0;   /* gewinnt — ohne !important, ohne Selektor-Aufblähung */
	}
}

Gemessen: funktioniert. Eine einzelne Klasse in app schlägt Bootstraps Komponentenregeln, egal wie diese aufgebaut sind. Der gesamte Werkzeugkasten aus „noch eine Klasse davor" und „.btn.btn.btn" entfällt ersatzlos.

Das ist der Punkt, an dem das Arbeiten mit einem Framework qualitativ kippt: Du musst dessen Selektoren nicht mehr kennen, um sie zu überschreiben. Du musst nur wissen, dass dein Layer später kommt.

Schritt 2: Die !important-Falle

Jetzt der Haken, den die meisten Anleitungen auslassen. Nach Regel 4 kehrt sich die Layer-Reihenfolge bei !important um. Bootstrap liegt in framework, also im frühesten Layer. Damit gilt:

Bootstraps 1.716 !important-Deklarationen sind in dieser Anordnung stärker als deine eigenen !important-Deklarationen in app.

Getestet mit Bootstraps .text-danger { color: … !important } gegen ein !important im späteren app-Layer: Bootstrap gewinnt. Das ist kein Bug, sondern die logische Folge von Regel 4 — aber es überrascht zuverlässig jeden, der es zum ersten Mal trifft.

Schritt 3: Der Override-Layer

Die Lösung ist ein Layer, der noch vor dem Framework liegt und ausschließlich für !important-Gegenwehr da ist:

@layer bs-overrides, bootstrap, app;

@import url("bootstrap.css") layer(bootstrap);

@layer bs-overrides {
	/* Nur !important-Regeln, die Bootstrap-Utilities aushebeln sollen. */
	.text-danger { color: var(--brand-danger) !important; }
}

@layer app {
	/* Alles Normale — schlägt Bootstrap ohne !important. */
	.btn { background: var(--brand); }
}

Gemessen: beides funktioniert. bs-overrides gewinnt das !important-Duell (früherer Layer), app gewinnt das normale Duell (späterer Layer). Ein Layer für jede Richtung.

Das wirkt zunächst wie ein Kunstgriff, ist aber sauber begründbar: Du hast zwei verschiedene Absichten — „ich möchte den Normalfall ergänzen" und „ich möchte eine Notbremse des Frameworks aushebeln" — und in einer Kaskade mit Umkehr brauchen zwei Absichten zwei Plätze.

Die bessere Lösung, wenn du Bootstrap selbst kompilierst: Schalte die !important an den Utilities einfach ab. Bootstrap hat dafür eine Sass-Variable — Sass ist ein CSS-Präprozessor mit eigener Sprache (Variablen mit $name, Mixins, Verschachtelung), den ein Build-Schritt zu CSS kompiliert; SCSS ist dessen CSS-nahe Schreibweise:

$enable-important-utilities: false;
@import "bootstrap/scss/bootstrap";

Ohne !important an den Utilities entfällt das gesamte zweite Layer-Problem, und die reine Layer-Reihenfolge reicht. Diese Variable gibt es seit Bootstrap 5.0 und sie steht in scss/_variables.scss auf true !default.

Der Sass-Fallstrick

Wer Bootstrap per Sass einbindet, stößt auf eine echte Werkzeug-Grenze: @use darf in Sass nicht innerhalb eines Blocks stehen. (@use lädt eine Sass-Datei als Modul, dessen Variablen, Mixins und Funktionen unter einem Namensraum bereitstehen; @forward reicht sie weiter. Beide ersetzen das abgekündigte @import.) Das hier ist also kein gültiges Sass:

@layer bootstrap {
	@use "bootstrap/scss/bootstrap";   /* Fehler: @use muss auf oberster Ebene stehen */
}

Ein layer()-Argument für @use und @forward war 2022 zwar vorgeschlagen, wurde aber binnen weniger Tage abgelehnt — mit dem Hinweis, dass Sass dafür bereits ein Werkzeug hat. Genau das ist der saubere Weg:

meta.load-css() lädt ein Sass-Modul dort, wo du gerade stehst — also auch innerhalb eines @layer-Blocks:

@use "sass:meta";

@layer bootstrap {
	@include meta.load-css("bootstrap/scss/bootstrap");
}

Getestet mit Dart Sass 1.102.0: Das kompiliert zu @layer bootstrap { … } mit dem vollständigen Framework-CSS darin — und ohne Deprecation-Warnung. Wichtig ist der Unterschied zu @use: meta.load-css() gibt nur das CSS aus, es stellt dir Variablen, Mixins und Funktionen des Moduls nicht zur Verfügung. Die lädst du weiterhin oben per @use — und genau diese Arbeitsteilung ist der Punkt.

Damit hast du drei gangbare Wege, in dieser Reihenfolge:

  1. meta.load-css() innerhalb des Layers. Der offizielle Weg, kein Verfallsdatum, keine zusätzliche Datei. Erste Wahl, wenn das Framework ohnehin durch deine Sass-Pipeline läuft.
  2. Framework separat kompilieren. Bootstrap zu einer eigenen .css-Datei bauen und per @import url(…) layer(bootstrap) aus einem schlanken CSS-Einstiegspunkt holen. Sauberste Trennung, unabhängig von Sass — sinnvoll, wenn du das Framework gar nicht anpasst.
  3. Nach dem Build umwickeln. Ein PostCSS-Schritt legt den kompletten kompilierten Framework-Output in einen Layer. Robust und toolchain-unabhängig, kostet aber einen zusätzlichen Build-Schritt.

Das früher übliche verschachtelte @import innerhalb @layer { } funktioniert weiterhin, wirft aber eine Deprecation-Warnung — @import wird in Dart Sass 3.0 entfernt. Für neuen Code also nicht mehr verwenden.

Und jetzt der Fehler, der eine ganze Layer-Architektur lautlos wertlos macht.

Ein <link rel="stylesheet"> kann heute keinem Layer zugewiesen werden. Es gibt keinen layer-Attributwert für <link>. Der entsprechende Vorschlag liegt seit Jahren bei der CSS Working Group, ist aber in keinem Browser umgesetzt.

Die Konsequenz ist nach Regel 3 hart:

<link rel="stylesheet" href="fremd.css">   <!-- ungelayert -->
<style>
	@layer app { html body #box { color: blue; } }   /* Spezifität 0-1-0-2 */
</style>
/* fremd.css */
#box { color: red; }                                 /* Spezifität 0-1-0-0 */

Gemessen: rot. Die schwächere Regel aus dem verlinkten Stylesheet gewinnt, weil sie ungelayert ist. Deine sorgfältig gebaute Layer-Ordnung ist gegen sie machtlos.

Der Workaround ist ein Zwischen-Stylesheet: Eine winzige CSS-Datei, die nichts tut, außer die eigentlichen Dateien in Layer zu importieren.

/* main.css — die einzige Datei, die per <link> geladen wird */
@layer reset, framework, base, components, utilities;

@import url("reset.css")      layer(reset);
@import url("bootstrap.css")  layer(framework);
@import url("base.css")       layer(base);
@import url("components.css") layer(components);
@import url("utilities.css")  layer(utilities);

Damit hast du aber ein Performance-Problem eingetauscht: @import-Ketten werden seriell aufgelöst. Der Browser muss main.css vollständig laden und parsen, bevor er überhaupt weiß, dass er bootstrap.css braucht — und CSS blockiert das Rendering, zeigt also nichts an, bevor es geladen und geparst ist (sonst blitzte kurz ungestyltes HTML auf). Bei fünf Importen wartest du auf mindestens zwei volle Roundtrips — ein Roundtrip ist ein kompletter Weg Anfrage-Antwort zum Server und zurück.

Die praktikable Antwort für Produktion: Bündeln zur Build-Zeit. Dein Bundler (esbuild, Vite, Lightning CSS, PostCSS — Letzteres kein Präprozessor mit eigener Sprache wie Sass, sondern ein Werkzeug, das CSS im Build über JavaScript-Plugins umschreibt) löst die @import-Anweisungen auf und schreibt die @layer-Blöcke direkt in eine Datei. Du behältst die saubere Quellstruktur, der Browser bekommt eine einzige Anfrage. Wer @import zur Laufzeit stehen lässt, sollte die betroffenen Dateien zumindest per <link rel="preload"> anstoßen.

Praxis 3: Tailwind CSS v4 — die Layer sind schon da

Wer Tailwind in Version 4 einsetzt, arbeitet bereits mit echten Cascade Layers, ob er will oder nicht. Tailwind v3 hatte ein @layer-Konstrukt, das nur aussah wie die CSS-At-Rule: Es war eine PostCSS-Direktive, die zur Build-Zeit ausgewertet und wieder entfernt wurde. Version 4 nutzt native Layer und schreibt sie in den Output.

Das lässt sich am Build dieser Seite hier direkt zeigen. Der Kopf der generierten main.css (Tailwind 4.3.0):

/*! tailwindcss v4.3.0 | MIT License | https://tailwindcss.com */
@layer properties { … }
@layer theme      { … }
@layer base       { … }
@layer components { … }
@layer utilities  { … }

Fünf Layer in dieser Reihenfolge. Der erste davon ist bedarfsabhängig: properties erscheint nur, wenn die erzeugten Utilities tatsächlich registrierte Custom Properties brauchen — ein Minimal-Build ohne solche Klassen gibt nur theme, base, components, utilities aus. Was das für deinen eigenen Code bedeutet:

Eigenes CSS ohne Layer schlägt alle Tailwind-Utilities. Das folgt aus Regel 3 und ist die häufigste Ursache für „meine Utility-Klasse wirkt nicht". Wenn du eine eigene Komponentendatei per @import neben Tailwind lädst und sie in keinem Layer liegt, gewinnt sie gegen @layer utilities — und damit gegen p-4, text-center und alles andere.

Die Lösung ist, eigenes CSS ebenfalls einzuordnen:

@import "tailwindcss";

/* Eigene Komponenten unterhalb der Utilities einsortieren, damit
   Utility-Klassen im Markup weiterhin das letzte Wort haben. */
@layer components {
	.prose-card {
		border-radius: 0.75rem;
		padding: 1.5rem;
	}
}

Für neue Utilities gibt es einen eigenen Weg. Tailwind v4 bringt die Direktive @utility mit, die eine Klasse als echte Utility registriert — du schreibst sie direkt in deine CSS-Datei (@utility tab-4 { tab-size: 4; }). Dazu gehören Varianten-Unterstützung (die Präfixe hover: und md:, die eine Utility an einen Zustand oder einen Breakpoint binden) und die korrekte Einsortierung in den utilities-Layer. Ein handgeschriebenes @layer utilities { … } bekommt das nicht mit.

Und ein Framework über dem Framework? Wenn du Tailwind mit einem weiteren Fremd-Stylesheet kombinierst, kannst du die gesamte Tailwind-Ausgabe in einen übergeordneten Layer verschieben, indem du den Import layerst:

@layer vendor, tw, app;
@import "tailwindcss" layer(tw);

Tailwinds eigene Layer werden dadurch zu tw.theme, tw.base, tw.components und tw.utilities — die interne Reihenfolge bleibt erhalten, der Block rückt als Ganzes zwischen vendor und app. Das ist die verschachtelte Namensraum-Kapselung aus dem Syntax-Kapitel in ihrer nützlichsten Form.

Eine Ausnahme gibt es dabei, und sie ist am realen Build nachgemessen: properties entkommt der Verschachtelung. Der Layer bleibt auf oberster Ebene und wird sogar noch vor vendor deklariert — er ist damit der schwächste im ganzen Dokument. Das ist unkritisch, weil er nur Ausgangswerte liefert, aber es heißt: Von den fünf Layern wandern nur vier in den Namensraum.

Womit auch geklärt wäre, was properties überhaupt tut: Er enthält einen @supports-Block, der registrierte Custom Properties dort nachbildet, wo der Browser sie noch nicht kennt. Die Bedingung zielt auf zwei Engines gleichzeitig — ältere Safari- und ältere Firefox-Versionen (vor 128).

Praxis 4: Fremd-Widgets, Embeds und Legacy-Code

Der dritte klassische Anwendungsfall ist alles, was du nicht kontrollierst und nicht anfassen willst: eingebettete Buchungs-Widgets, Consent-Banner, Chat-Fenster, das CSS eines alten Bereichs, den niemand mehr versteht.

Der Reflex ist hier der anonyme Layer — und genau daran lässt sich eine Falle zeigen, in die man erstaunlich leicht tappt. Dieses Beispiel tut nicht, was es verspricht:

@layer app;                         /* ❌ app ist jetzt der ERSTE Layer */
@import url("widget.css") layer;    /* ❌ der anonyme Layer entsteht DANACH */

Gemessen: Das Widget gewinnt. Die Erklärung ist Regel 2 — die Reihenfolge steht bei der ersten Erwähnung fest. @layer app; erwähnt app zuerst und macht es damit zum schwächsten Layer; der anonyme Layer des Imports entsteht danach und ist folglich stärker. Anonyme Layer haben genau diese Eigenheit: Weil sie keinen Namen tragen, kannst du sie in keiner Reihenfolgezeile vorziehen. Ihre Position ergibt sich allein daraus, wo der Import steht.

Richtig ist deshalb ein benannter Vendor-Layer, der in der Reihenfolgezeile ganz vorne steht:

@layer widget, app;                        /* Fremdcode zuerst = schwächster Layer */

@import url("widget.css") layer(widget);

@layer app {
	/* dein Code — gewinnt jede normale Auseinandersetzung */
}

Gemessen: funktioniert. Du legst den Fremdcode in einen Layer, der ganz vorne steht, und musst ihn danach nie wieder erwähnen. Niemand erweitert ihn versehentlich, und dein eigenes CSS gewinnt jede normale Auseinandersetzung.

Wenn der Layer wirklich anonym bleiben soll, muss der Import vor allem Eigenen stehen — dann stimmt die Reihenfolge, aber du gibst die Möglichkeit auf, sie später explizit zu dokumentieren. In der Praxis ist der benannte Layer fast immer die bessere Wahl.

Für Legacy-Code im eigenen Haus ist ein benannter Layer besser:

@layer legacy, modern;

@layer legacy {
	/* Der alte Bereich, unverändert hineinkopiert. */
}

Der Effekt ist ein technischer Schuldenschnitt: Alles in legacy verliert automatisch gegen alles Neue. Du kannst alten Code liegenlassen, statt ihn zu entschärfen — und Stück für Stück herausziehen, ohne dass sich zwischendurch die Prioritäten verschieben.

Web Components: was Layer können — und was nicht

Kurz zum Vokabular, bevor es losgeht: Web Components ist der Sammelbegriff für eigene HTML-Elemente mit gekapseltem Innenleben. Ein Custom Element ist so ein selbst definiertes Element (Name mit Bindestrich, per customElements.define() registriert); an ihm hängt ein Shadow DOM — ein eigener, abgeschotteter DOM-Baum, dessen Styles gescopt sind und in den normale Dokument-Selektoren nicht hineingreifen.

Hier wird es interessant, denn die verbreitete Erwartung ist falsch. Wer @layer als „Prioritätssystem für CSS" verstanden hat, nimmt an, dass sich damit auch die Grenze zwischen Seite und Web Component regeln lässt. Das geht nicht — und zwar aus einem sehr klaren Grund.

Der Kapselungskontext steht über den Layern

Erinnere dich an die Sortierreihenfolge aus dem zweiten Kapitel: Kapselungskontext steht auf Platz 2, Layer erst auf Platz 4. Kapselungskontext heißt schlicht: aus welchem Baum die Deklaration stammt — Dokument oder Shadow-Baum. Der Browser prüft also zuerst, aus welchem Baum eine Deklaration stammt, und kommt zur Layer-Frage überhaupt nur, wenn beide aus demselben Baum kommen.

Die Regel für den Kapselungskontext lautet:

Bei konkurrierenden Deklarationen für dasselbe Element gewinnt bei normalen Deklarationen der äußere Baum, bei !important der innere.

Das Wort „konkurrierend" trägt hier Gewicht. Die Regel greift nur, wo überhaupt zwei Deklarationen um dasselbe Element streiten — also bei :host, ::part() und ::slotted(). Überall dort, wo das Dokument die Elemente im Shadow-Baum gar nicht selektieren kann, gewinnt die Komponente ohnehin, und ein geerbter Wert wird von jeder Shadow-Regel geschlagen.

Gemessen an einem Custom Element mit offenem Shadow Root:

<style>
	#karte { color: green; }              /* Dokument, normal */
</style>
<my-card id="karte">…</my-card>
shadow.innerHTML = `<style>
	@layer a, b;
	@layer b { :host { color: red; } }    /* Shadow-Baum, normal, letzter Layer */
</style><slot></slot>`;

Gemessen: grün. Die Dokumentregel gewinnt — obwohl die Shadow-Regel im späteren Layer ihres Baums liegt. Der Layer wurde gar nicht erst befragt.

Und die Gegenprobe mit !important:

<style>#karte { color: green !important; }</style>
shadow.innerHTML = `<style>:host { color: blue !important; }</style><slot></slot>`;

Gemessen: blau. Jetzt gewinnt der innere Baum.

Layer-Namen überqueren keine Shadow-Grenze

Der zweite wichtige Punkt: Jeder Shadow-Baum hat seine eigene, völlig unabhängige Layer-Reihenfolge. Ein @layer components im Dokument und ein @layer components im Shadow Root sind zwei verschiedene Dinge, die nichts voneinander wissen.

Gemessen: Deklariert das Dokument @layer b, a; und der Shadow-Baum erwähnt ohne eigene Reihenfolgezeile erst a und dann b, so gilt im Inneren die dortige Erstnennung — die Reihenfolgezeile des Dokuments bleibt wirkungslos.

Das ist konsistent mit dem Zweck von Shadow DOM: Kapselung ist genau die Zusage, dass äußere Struktur innen nichts bedeutet. Zwei Präzisierungen gehören aber dazu, weil die Kurzfassung „Layer und Shadow DOM haben nichts miteinander zu tun" in beide Richtungen zu grob ist.

Erstens: Ein globales @layer reset erreicht Shadow-Bäume teilweise sehr wohl. Selektor-Gebundenes bleibt draußen — * { box-sizing: border-box } aus dem Dokument wirkt im Shadow-Baum nicht. Vererbbare Eigenschaften fließen dagegen wie eh und je hinein: Ein font-family: monospace und ein letter-spacing: 3px aus dem Dokument-Reset waren im Shadow-Baum messbar wirksam. Da ein realer Reset aus beidem besteht, kommt sein Typografie-Teil vollständig an.

Zweitens: Eine Layer-API nach außen ist möglich — aber nur über den Baum, nicht über die Dokumentgrenze. Solange dein CSS im Dokument liegt, kannst du der Komponente keinen Layer unterschieben; der Kapselungskontext entscheidet vorher. Landet dein Stylesheet dagegen im Shadow-Baum — weil die Komponente ein CSSStyleSheet entgegennimmt und adoptiert, oder weil ihr Shadow Root offen ist und du ein <style> hineinhängst —, teilt es den internen Layer-Namensraum. Gemessen: Ein hineingegebenes @layer a { … } landet dann tatsächlich im vorhandenen internen Layer a und ordnet sich dessen Rang unter, statt einen neuen Layer am Ende anzulegen.

Das ist die technische Grundlage des verbreiteten Theme-Injection-Musters — und ein Vertrag, den die Komponente bewusst eingehen muss. In der CSS Working Group laufen dazu weitergehende Vorschläge; verlass dich für Produktionscode noch nicht darauf.

:host ist absichtlich schwach

Aus der Kontext-Regel folgt eine Eigenschaft, die man beim Bau von Komponenten kennen muss: :host-Regeln sind für den Konsumenten leicht zu überschreiben. (:host selektiert von innen heraus das Element, an dem der Shadow Root hängt — wirksam nur in Stilen, die innerhalb dieses Shadow Roots stehen.) Jede Dokumentregel, die das Host-Element trifft, gewinnt gegen jede normale :host-Regel — unabhängig von Spezifität und Layern.

Das ist eine bewusste Design-Entscheidung der Plattform: :host setzt Vorgaben, keine Vorschriften. Der Versuch, das mit Spezifität zu erzwingen, scheitert zuverlässig — auch :host(.a.b.c.d.e) und :host-context(body) verlieren gegen einen simplen #id im Dokument.

Wer als Komponentenautor etwas wirklich festnageln muss (etwa display oder box-sizing, damit das Layout nicht bricht), hat zwei Möglichkeiten:

  • !important auf :host — und bekommt es dank der Kontext-Umkehr durchgesetzt. Wie stark das ist, zeigt ein Nebenbefund: :host { … !important } schlägt sogar ein !important im style-Attribut des Host-Elements. Es ist die einzige Stelle im ganzen Artikel, an der Inline-Styles verlieren — weil der Kapselungskontext in der Sortierreihenfolge noch vor den element-gebundenen Styles geprüft wird.
  • Gar nicht auf :host setzen. Leg die kritische Eigenschaft stattdessen auf einen inneren Wrapper im Shadow-Baum. Ohne ::part() hat das Dokument dort schlicht keinen Selektor, der hinreicht — der robustere Weg, wenn es nur darum geht, dass das Layout nicht bricht.

Wofür Layer in Web Components dann gut sind

Innerhalb des Shadow Roots sind Cascade Layers uneingeschränkt nützlich, und zwar für genau denselben Zweck wie im Dokument: interne Ordnung.

Ein Wort zum verwendeten Mechanismus: adoptedStyleSheets hängt ein per JavaScript gebautes CSSStyleSheet an einen Shadow Root. Der Vorteil gegenüber einem <style>-Tag ist, dass sich ein Blatt von vielen Instanzen derselben Komponente teilen lässt.

const sheet = new CSSStyleSheet();
sheet.replaceSync(`
	@layer defaults, parts, state;

	@layer defaults {
		:host { display: block; box-sizing: border-box; }
		button { all: unset; cursor: pointer; }
	}

	@layer parts {
		button { padding: 0.5em 1em; border-radius: 0.4rem; }
	}

	@layer state {
		:host([disabled]) button { opacity: 0.5; cursor: not-allowed; }
	}
`);
shadow.adoptedStyleSheets = [sheet];

Der Nutzen ist derselbe wie überall: Der Zustands-Layer gewinnt gegen den Teile-Layer, ohne dass du :host([disabled]) button künstlich spezifischer machen musst. In einer Komponente mit vielen Zuständen ([disabled], [loading], [aria-invalid]) spart das genau die verschachtelten Selektoren, die Komponenten-CSS sonst unlesbar machen.

Ein Detail zu adoptedStyleSheets: Adoptierte Stylesheets werden nach den <style>-Elementen desselben Baums einsortiert. Bei Gleichstand in allem anderen gewinnt also das adoptierte Blatt. Über das Zusammenspiel von adoptierten Stylesheets, Layern und teilweise offenen Shadow Roots wird in der CSS Working Group aktiv diskutiert; das ist derzeit noch Bewegung im Standard und kein Fundament für Produktionscode.

Die Kurzfassung für Komponentenautoren

Frage Antwort
Layer innerhalb der Komponente nutzen? Ja, uneingeschränkt sinnvoll
Layer als Styling-API nach außen anbieten? Nur über einen expliziten Vertrag — adoptiertes Stylesheet oder offener Shadow Root
Vom Dokument aus in einen Komponenten-Layer schreiben? Nein — der Kapselungskontext entscheidet vorher
:host-Vorgaben vor Überschreiben schützen? Mit !important — oder gar nicht erst auf :host setzen, sondern auf einen internen Wrapper
Globale Layer-Reihenfolge beeinflusst die Komponente? Ihre interne Rangfolge nicht. Alles, was von außen ankommt (::part(), geslottete Inhalte, geerbte Werte), sehr wohl
Was ist die richtige Styling-API? Custom Properties und ::part() — beide funktionieren wie vorher

Zu ::part(): Die Komponente markiert innere Elemente mit part="label"; von außen stylst du sie dann per my-card::part(label) { … }. Das ist der offizielle, vom Autor der Komponente freigegebene Weg nach innen.

Design-Tokens mit Layer-Metadaten

Zum Schluss ein fortgeschrittenes Muster, das die Layer-Zuordnung dahin verlagert, wo sie hingehört: in die Datenquelle.

Das Problem

Wenn Design-Tokens von Hand als CSS geschrieben werden, ist die Layer-Frage trivial — du tippst @layer und fertig. Design-Tokens sind benannte Design-Entscheidungen — Farben, Abstände, Schriftgrößen — zentral in einer Datei gepflegt, meist als JSON. In jedem ernsthaften Design-System werden Custom Properties daraus generiert, über einen Build-Schritt. Und diese Token-Datei kennt keine Layer:

{
	"card": {
		"background": { "$value": "#1c2935", "$type": "color" }
	}
}

Der Generator hat keine Möglichkeit zu wissen, dass dieses Token zur Card-Komponente gehört und entsprechend in einen Komponenten-Layer sollte. Also landet üblicherweise alles in einem einzigen :root-Block — und die gesamte Layer-Struktur des Systems endet an der Token-Grenze. Bei zwanzig Komponenten fällt das nicht auf; bei zweihundert wird es zu genau dem Ordnungsproblem, das @layer eigentlich lösen sollte.

$extensions statt eines eigenen Schlüssels

Der naheliegende Reflex wäre, einfach ein Feld zu erfinden:

{ "$value": "#1c2935", "$type": "color", "cssLayer": "components.card" }

Das ist eine schlechte Idee, und der Grund steht in der Spezifikation. Im Token-Format kennzeichnet das Dollarzeichen reservierte Schlüssel — $value, $type, $description —, und das Design Tokens Format Module (englisch) der Design Tokens Community Group (DTCG) beim W3C sieht für eigene Zusatzdaten das Feld $extensions vor — mit einer harten Zusage und einer Empfehlung.

Die harte Zusage ist eine Erhaltungspflicht:

„Tools that process design token files MUST preserve any extension data they do not themselves understand."

Ein Werkzeug, das deine Token-Datei liest und neu schreibt, muss unbekannte $extensions erhalten. Für einen selbst erfundenen Schlüssel auf oberster Ebene gibt es diese Garantie nicht — er darf beim nächsten Durchlauf durch ein fremdes Werkzeug verschwinden.

Die Empfehlung betrifft die Benennung:

„The keys SHOULD be chosen such that they avoid the likelihood of a naming clash with another vendor's data. The reverse domain name notation is recommended for this purpose."

Umgekehrte Domain-Notation heißt: die Bestandteile deiner Domain in umgekehrter Reihenfolge schreiben. Aus jpkc.com wird com.jpkc — ein Namensraum, der eindeutig einem Anbieter gehört. Aus Java und iOS kennst du das Muster.

Eine dritte Stelle derselben Sektion ist ein Dämpfer, den man kennen sollte: Die Spezifikation bittet darum, $extensions auf optionale Metadaten zu beschränken, die zum Verständnis des Token-Werts nicht nötig sind. Das ist hier erfüllt — die Farbe #1c2935 steht für sich, auch ohne zu wissen, in welchen Layer sie gehört. Aber es heißt auch: Geht die Extension unterwegs verloren, ist dein Build nicht falsch, sondern nur ungelayert. Das merkt niemand beim Draufschauen — also lass es deine CI prüfen.

Damit sieht eine Token-Datei mit Layer-Zuordnung so aus — hier vollständig, damit du den Output weiter unten nachvollziehen kannst. Die äußere Gruppe color bestimmt das Präfix der erzeugten Custom Properties; brand bleibt bewusst ohne Layer-Angabe und landet später im ungelayerten :root:

{
	"color": {
		"brand": { "$value": "#2f5468", "$type": "color" },
		"card": {
			"background": {
				"$value": "#1c2935",
				"$type": "color",
				"$extensions": {
					"com.jpkc/css-layer": { "layer": "components.card" }
				}
			}
		},
		"button": {
			"background": {
				"$value": "#8fb3c6",
				"$type": "color",
				"$extensions": {
					"com.jpkc/css-layer": { "layer": "components.button" }
				}
			}
		}
	}
}

Der Token-Wert bleibt plattformneutral — für iOS oder Android ist die Layer-Angabe schlicht unbekanntes Beiwerk, das ignoriert (aber erhalten) wird. Nur der CSS-Generator wertet sie aus. Das ist sauberer als jede Lösung, die CSS-Konzepte in die Token-Werte selbst schreibt.

Der Generator

Der zweite Teil ist ein Format-Hook, der die Tokens nach ihrer Layer-Angabe gruppiert. In Style Dictionary (englisch) — dem verbreitetsten Token-Compiler — sind das rund 30 Zeilen. Ein Token-Compiler liest deine Token-Datei und erzeugt daraus pro Plattform passende Dateien: CSS-Custom-Properties fürs Web, Konstanten für iOS und Android. Der folgende Code ist gegen Style Dictionary 5.5.1 getestet, nicht geschätzt:

import StyleDictionary from 'style-dictionary';

const EXT_KEY = 'com.jpkc/css-layer';

StyleDictionary.registerFormat({
	name: 'css/variables-layered',
	format: ({ dictionary, options }) => {
		// Welche Form die Tokens haben, hängt an der Quelldatei — SD meldet es hier.
		const dtcg = options.usesDtcg;
		const val = (t) => (dtcg ? t.$value : t.value);
		const ext = (t) => (dtcg ? t.$extensions : t.extensions) ?? t.original?.$extensions;

		const layers = new Map();
		const plain = [];

		for (const token of dictionary.allTokens) {
			const layer = ext(token)?.[EXT_KEY]?.layer;
			if (!layer) { plain.push(token); continue; }
			if (!layers.has(layer)) layers.set(layer, []);
			layers.get(layer).push(token);
		}

		const decl = (t, pad) => `${pad}--${t.name}: ${val(t)};`;
		let out = '';

		// Reihenfolge explizit voranstellen — sonst entscheidet die Token-Sortierung.
		if (layers.size) out += `@layer ${[...layers.keys()].join(', ')};\n\n`;
		if (plain.length) out += `:root {\n${plain.map((t) => decl(t, '\t')).join('\n')}\n}\n\n`;

		for (const [name, tokens] of layers) {
			out += `@layer ${name} {\n\t:root {\n${tokens.map((t) => decl(t, '\t\t')).join('\n')}\n\t}\n}\n\n`;
		}
		return out;
	},
});

const sd = new StyleDictionary({
	source: ['tokens/tokens.json'],
	usesDtcg: true,                        // optional — SD erkennt DTCG auch selbst
	platforms: {
		css: {
			transformGroup: 'css',
			buildPath: 'build/',
			files: [{ destination: 'tokens.css', format: 'css/variables-layered' }],
		},
	},
});

await sd.buildAllPlatforms();

Der erzeugte Output:

@layer components.card, components.button;

:root {
	--color-brand: #2f5468;
}

@layer components.card {
	:root {
		--color-card-background: #1c2935;
	}
}

@layer components.button {
	:root {
		--color-button-background: #8fb3c6;
	}
}

Zwei Details, die den Unterschied zwischen „funktioniert im Beispiel" und „funktioniert im Projekt" ausmachen:

usesDtcg musst du meist gar nicht setzen — du darfst es nur nicht raten. Style Dictionary erkennt das DTCG-Format an der Quelldatei selbst; lässt man die Option weg, ist der Output byte-identisch (nachgemessen). Sie explizit zu setzen dokumentiert also nur deine Erwartung.

Gefährlich ist die falsche Annahme im Hook. Liest ein Hook, der token.value erwartet, eine DTCG-Datei mit $value, entsteht ein Output voller --foo: undefined; — ohne Fehlermeldung, Exit-Code 0. Und wer die Option gegen die Quelldatei erzwingt (usesDtcg: false auf einer $value-Datei), bekommt eine leere Ausgabedatei, ebenfalls ohne Fehler. Genau deshalb liest der Hook oben options.usesDtcg aus, statt eine der beiden Formen anzunehmen — dann funktioniert er mit alten und neuen Token-Dateien gleichermaßen.

Die Layer-Reihenfolge muss explizit vorangestellt werden. Ohne die einleitende @layer …;-Zeile entstünde die Reihenfolge zufällig aus der Sortierung der Tokens in der Quelldatei. Ein umbenanntes Token könnte dann die Kaskade umkrempeln — der denkbar unangenehmste Bug, weil die Ursache in einer JSON-Datei liegt und die Wirkung im gerenderten Layout.

Der Haken: Tokens im Layer sind schwächer

Und jetzt der Teil, der bei diesem Muster ehrlicherweise dazugehört, weil er das Ergebnis in manchen Projekten unbrauchbar macht.

Sobald ein Token in einem Layer liegt, gilt für es Regel 3: Jede ungelayerte :root-Regel schlägt es. Gemessen:

@layer theme { :root { --brand: red; } }
:root { --brand: green; }                /* ungelayert */

Der aufgelöste Wert von --brand ist grün. Wer Themes über einen ungelayerten Block umschaltet — und das ist ein extrem verbreitetes Muster —, überfährt damit sämtliche gelayerten Tokens auf einen Schlag:

/* Kippt jedes gelayerte Token, ohne dass es jemand merkt: */
@media (prefers-color-scheme: dark) {
	:root { --brand: #8fb3c6; }
}

Daraus folgt eine klare Entscheidungsregel:

  • Tokens ungelayert lassen, wenn sie als unverrückbare Konstanten gedacht sind, die von überall gelesen und nirgends überschrieben werden. Das ist die Wahl, die auf jpkc.com getroffen wurde — der :root-Block steht dort bewusst vor und außerhalb der Layer-Deklaration.
  • Tokens layern, wenn Themes, White-Labels oder Komponentenvarianten sie planmäßig überschreiben sollen — White-Label heißt: dasselbe Produkt läuft bei mehreren Kunden unter deren eigener Marke, gleiche Funktion, ausgetauschte Farben und Logos. Dann aber konsequent: Auch die Theme-Overrides und jeder prefers-color-scheme-Block müssen in Layer, sonst gewinnt der Zufall.

Der Fehler ist die Mischform. Ein Teil der Tokens gelayert, der andere nicht — das erzeugt genau die Sorte Bug, die sich nur beim Dark-Mode-Wechsel auf einer bestimmten Unterseite zeigt.

Welche Layer-Reihenfolge? Ein konkreter Vorschlag

Es gibt keine kanonische Reihenfolge, aber es gibt eine, die sich bewährt hat und die im Kern der Logik von ITCSS folgt: von allgemein nach speziell, von fremd nach eigen.

@layer overrides, vendor, reset, base, layout, components, states, utilities;
Layer Inhalt Warum an dieser Stelle
overrides Ausschließlich !important-Notbremsen gegen Fremd-CSS Ganz vorne — bei !important ist das der stärkste Platz
vendor Bootstrap, Widgets, Consent-Banner, alles Fremde Früh, damit alles Eigene normal gewinnt
reset Normalisierung, box-sizing, Margin-Reset Grundlage für alles Eigene
base Element-Defaults: Typografie, Links, Tabellen, Formulare Baut auf dem Reset auf
layout Raster, Container, Seitengerüst Trägt Komponenten, wird von ihnen nicht überschrieben
components Buttons, Karten, Navigation, alles Wiederverwendbare Der Ort, an dem die meiste Arbeit passiert
states is-open, [disabled], [aria-current], Fehlerzustände Muss Komponenten schlagen, ohne spezifischer zu sein
utilities Einzweck-Klassen: .mt-0, .visually-hidden Letztes Wort im Markup — genau dafür sind sie da

Vier Anmerkungen dazu:

states als eigener Layer ist unterschätzt. Der klassische Schmerz „.card.is-active überschreibt .card__body .card__title nicht" verschwindet damit ersatzlos.

responsive gehört normalerweise nicht in einen eigenen Layer. Bei jpkc.com ist es einer, weil dort alle @media-Blöcke historisch am Dateiende stehen und die Umstellung diesen Zustand abbilden sollte. Der sauberere Weg für neue Projekte ist, Media Queries innerhalb des Layers zu schreiben, zu dem sie inhaltlich gehören — ein responsives Button-Verhalten gehört zu components. Ein eigener responsive-Layer erzwingt sonst, dass jede Breakpoint-Regel jede Komponentenregel schlägt, auch wenn das gar nicht gemeint war.

Weniger Layer sind besser als mehr. Vier bis sechs sind für die meisten Projekte genug. Jeder zusätzliche Layer ist eine weitere Regel, die alle im Team im Kopf haben müssen.

Ungelayertes CSS ist eine Entscheidung, kein Zustand. Weil es alles schlägt, sollte es entweder gar nicht vorkommen oder eine bewusst dokumentierte Rolle haben (etwa: nur Design-Tokens).

Debugging und Werkzeuge

Chrome und Edge DevTools zeigen Layer im Styles-Panel: Regeln aus einem Layer tragen ein @layer <name>-Etikett, und über die Schaltfläche Toggle CSS layers view neben dem Suchfeld bekommst du die vollständige Layer-Rangfolge des Elements — von der höchsten Priorität oben bis zur niedrigsten unten. Das ist beim Einführen von Layern das mit Abstand nützlichste Werkzeug, weil es die Frage „warum gewinnt diese Regel?" direkt beantwortet.

Firefox DevTools zeigen die Layer-Zugehörigkeit ebenfalls im Regel-Panel.

Per JavaScript lässt sich die Struktur prüfen: CSSLayerStatementRule (die Reihenfolge-Deklaration) und CSSLayerBlockRule (der Block) sind reguläre CSSOM-Objekte. Das CSSOM (CSS Object Model) ist der Objektbaum, als den der Browser jedes Stylesheet spiegelt — darüber liest JavaScript document.styleSheets und darin cssRules:

for (const sheet of document.styleSheets) {
	for (const rule of sheet.cssRules) {
		if (rule instanceof CSSLayerStatementRule) console.log('Reihenfolge:', rule.nameList);
		if (rule instanceof CSSLayerBlockRule)     console.log('Block:', rule.name);
	}
}

Damit lässt sich in der CI prüfen — der automatischen Prüfstrecke, die bei jedem Commit baut und testet —, ob der gebaute Output die erwartete Layer-Reihenfolge hat — eine Zusicherung, die deutlich mehr wert ist als jeder visuelle Test, weil sie die Ursache prüft statt der Wirkung.

Für den Ernstfall muss die Schleife allerdings absteigen: So wie oben sieht sie nur die oberste Ebene und übersieht damit Layer-Blöcke innerhalb anderer Layer, innerhalb von @media sowie alles in importierten Stylesheets. Vollständig wird sie erst, wenn sie in CSSGroupingRule.cssRules und CSSImportRule.styleSheet hineinrekursiert. Anonyme Blöcke melden dabei einen leeren name.

Browser-Support und Migration

@layer ist in allen Engines seit März 2022 verfügbar und trägt inzwischen den Baseline-Status Widely available. Baseline ist die Support-Einstufung der WebDX Community Group, wie sie MDN, web.dev und Can I use anzeigen: Newly available heißt, das Feature ist in allen Kern-Browsern angekommen, Widely available dasselbe 30 Monate später.

Browser Ab Version
Chrome / Edge 99
Firefox 97
Safari 15.4
Opera 86
Samsung Internet 18

Ein Punkt ist dabei kritisch und wird gern übersehen: Es gibt keinen sinnvollen Fallback. Ein Browser, der @layer nicht kennt, ignoriert nach den CSS-Fehlerbehandlungsregeln die At-Rule samt ihrem gesamten Block. Deine Buttons wären dort also nicht falsch priorisiert, sondern komplett ungestylt.

Eine Feature-Abfrage hilft dabei nicht weiter. Zwar gibt es seit Chrome 148 ein @supports at-rule(@layer), aber genau dort beißt sich die Katze in den Schwanz: Jeder Browser, der @layer nicht kennt, kennt erst recht kein at-rule(). Ein Laufzeit-Polyfill existiert ebenfalls nicht — ein Polyfill ist Code, meist JavaScript, der eine fehlende Plattformfunktion nachbaut, damit sie auch ohne native Unterstützung nutzbar ist. Die Kaskade lässt sich so aber nicht nachbauen, sondern nur, indem man sämtliche Selektoren umschreibt. Genau das tut @csstools/postcss-cascade-layers (Teil von postcss-preset-env) zur Build-Zeit, indem es Selektoren mit wiederholten :not(#\#) auf die passende Spezifität aufpolstert.

Verschärfend kommt hinzu: In einem Browser ohne @layer-Unterstützung ist auch @import url(x.css) layer(y) ein ungültiger @import — das Stylesheet ist dort also nicht falsch einsortiert, sondern lädt überhaupt nicht.

Die praktische Antwort lautet trotzdem: Bei über vier Jahren Verfügbarkeit in allen Engines ist das für nahezu jedes Publikum unproblematisch — aber schau einmal in deine Analytics, bevor du umstellst, statt es anzunehmen.

Häufige Fehler — die Checkliste

Symptom Ursache Lösung
Layer-Reihenfolge wirkt nicht Die @layer a, b;-Zeile steht nach den Blöcken Reihenfolge-Deklaration in die erste Zeile
Fremd-CSS gewinnt trotz Layer Es kommt aus einem <link> und ist ungelayert Über ein Wrapper-Stylesheet importieren oder beim Build bündeln
Eigenes !important verliert Der Layer des Frameworks liegt früher Separaten overrides-Layer vor das Framework legen
Utility-Klassen wirken nicht Eigenes CSS liegt ungelayert neben Tailwind Eigenes CSS in @layer components einordnen
Dark Mode kippt gelayerte Tokens Der prefers-color-scheme-Block ist ungelayert Theme-Overrides in denselben Layer wie die Tokens
Layer-Reihenfolge ändert sich je Viewport Ein Layer wird erstmals in einer @media-Bedingung angelegt Alle Layer außerhalb von Bedingungsblöcken deklarieren
@layer fehlt im Build-Output Der Minifier entfernt die At-Rules Minifier prüfen und im Zweifel wechseln
Shadow-DOM-Styling reagiert nicht auf Layer Der Kapselungskontext steht über den Layern Styling-API der Komponente nutzen (Custom Properties, ::part()) — die gehorcht dann wieder deiner Dokument-Layer-Reihenfolge
Regel gewinnt trotz letztem Layer nicht Inline-Style, laufende Animation oder Transition Layer greifen dort grundsätzlich nicht

FAQ

Ersetzt @layer Methodiken wie BEM? Nein, es entlastet sie. BEM löst Namenskonflikte und macht Zugehörigkeit im Markup lesbar; @layer löst Prioritätskonflikte. Was @layer überflüssig macht, sind die spezifitätsgetriebenen Teile solcher Konventionen — das künstliche Verdoppeln von Klassen, das Hochzählen von Selektoren, die Regel „Utilities müssen ganz unten stehen".

Kann ich @layer schrittweise einführen? Ja, und das ist der empfohlene Weg. Weil ungelayertes CSS alles Gelayerte schlägt, kannst du zuerst nur das Fremd-CSS in einen Layer ziehen. Dein gesamter Bestand bleibt unverändert stark, und du hast trotzdem sofort das Framework unter Kontrolle.

Was passiert bei doppelten Layer-Namen in verschiedenen Dateien? Sie bezeichnen denselben Layer, sofern beide Dateien im selben Baum liegen. Das ist gewollt — so kannst du @layer components { … } über viele Dateien verteilen. Innerhalb von Shadow-Bäumen gilt das nicht: Dort ist jeder Baum ein eigener Namensraum.

Sind Layer schlecht für die Performance? Nein. Die Messung an einer real inline eingebetteten Datei ergab +83 Byte pro Seite nach Minifizierung — je nach Seitengröße 0,06 bis 0,18 %; die Auswertung passiert einmal beim Erstellen der Stylesheet-Reihenfolge, nicht pro Element. Der einzige reale Performance-Aspekt ist das serielle Laden bei @import-Ketten — und das ist ein @import-Problem, kein Layer-Problem.

Wie verhält sich @layer zu @scope? Die beiden ergänzen sich und sind unabhängig. @layer beantwortet „welche Schicht gewinnt", @scope beantwortet „auf welchen DOM-Bereich zielt diese Regel". In der Sortierreihenfolge ist die Scope-Nähe eine eigene Stufe direkt nach der Spezifität — Level 5 der Spezifikation kennt sie noch nicht, Level 6 fügt sie ein. Sie liegt damit weit hinter der Layer-Entscheidung, und eine nähere Scope-Wurzel erhöht nicht die Spezifität: Sie entscheidet erst, wenn die Spezifität gleich ist. Ein @scope-Block darf in einem Layer stehen und umgekehrt.

Was mache ich mit !important im Bestand? Inventarisieren, bevor du Layer einführst. Jedes !important verhält sich nach der Umstellung anders als vorher, weil es der Layer-Reihenfolge nun rückwärts folgt. Der Idealfall ist, die meisten davon zu löschen — genau dafür ist @layer gedacht. Was übrig bleibt, gehört in einen bewusst platzierten overrides-Layer statt verstreut über die Codebasis.

Fazit

Cascade Layers sind eine der wenigen CSS-Neuerungen, die man an einem Nachmittag versteht und trotzdem jahrelang nutzt. Der Kern ist eine Zeile:

@layer reset, base, components, utilities;

Ab diesem Moment ist die Frage „welche Regel gewinnt?" nicht mehr eine Frage an den Selektor, sondern an die Architektur. Und das ist die Frage, die du eigentlich stellen wolltest.

Die drei Punkte, die aus der Praxis hängen bleiben:

  1. Ungelayertes CSS schlägt alles Gelayerte. Diese eine Regel erklärt gefühlt die Hälfte aller Überraschungen — und macht gleichzeitig die schrittweise Migration erst möglich.
  2. !important dreht die Reihenfolge um. Wer ein Framework in den ersten Layer legt, macht dessen !important-Regeln unbesiegbar, wenn er nicht davor noch einen Override-Layer einplant.
  3. Über die Dokumentgrenze hinweg helfen Layer nicht. Der Kapselungskontext steht in der Kaskade über ihnen, deshalb erreichst du eine Komponente nicht per Layer. Custom Properties und ::part() bleiben die Styling-API — und die gehorcht deiner Layer-Reihenfolge dann wieder ganz normal.

Wenn du es ausprobieren willst, ohne ein Projekt anzufassen: Der Playground hat einen HTML/CSS-Editor mit Live-Vorschau — die Beispiele aus diesem Artikel lassen sich dort direkt einfügen und verändern.

Glossar

Begriffe, die im Text vorkommen und dort den Satz überladen hätten. Wer sie kennt, überspringt die Tabelle.

Begriff Bedeutung
all: unset Die Kurzschrift all setzt alle Eigenschaften außer direction und unicode-bidi zurück — hier entfernt sie das Browser-Styling des Buttons.
Attribut-Selektor Eckige Klammern treffen Elemente mit einem Attribut: [disabled] jedes, das es trägt, :host([disabled]) den Host nur dann.
BEM-Notation Doppelunterstrich trennt Block von Element (.card__body ist der Body-Teil der Card); das is--Präfix markiert Zustände (aus SMACSS).
Breakpoint Viewport-Schwelle, ab der das Layout umschaltet.
display / box-sizing Ohne eigenes display ist ein Custom Element inline, width und height greifen nicht; box-sizing: border-box rechnet Padding und Rahmen in die Breite ein.
Distribution (dist/) Das fertig kompilierte CSS eines Frameworks — genau die Datei, die du per @import layern kannst; die Sass-Quellen liegen daneben in scss/.
@import "tailwindcss" Der nackte Paketname statt url() wird vom Build-Werkzeug aufgelöst und durch Tailwinds CSS ersetzt — im Browser wäre er eine relative URL ins Leere.
inverted-colors Barrierefreiheits-Media-Feature: erkennt vom Betriebssystem oder Browser invertierte Farben (none/inverted); praktisch unterstützt nur Safari es.
Mixin Benannter Sass-Baustein, den du per @include einsetzt; die Definition allein gibt kein CSS aus.
platforms / buildPath / transformGroup Style Dictionary: platforms listet die Ziele, buildPath ist das Ausgabeverzeichnis, transformGroup: 'css' bündelt die Umwandlungen — darunter die Kebab-Case-Namen.
Print-Stylesheet Der @media print-Block für die Druckausgabe.
Pseudo-Klasse / Pseudo-Element Ein Doppelpunkt = Zustand des Elements (:target, :focus-visible), zwei Doppelpunkte = gerenderter Teil ohne eigenen DOM-Knoten (::selection); Ausnahme ist das alte :before.
Reset Vereinheitlicht Browser-Voreinstellungen und setzt eigene Basiswerte — meist box-sizing: border-box und genullte Außenabstände; Tailwinds Variante heißt Preflight und baut auf modern-normalize auf.
Selektor Bestimmt, welche Elemente eine Regel trifft: Leerzeichen heißt „irgendwo darin" (.sidebar a), kein Leerzeichen „dasselbe Element" (.btn.plain).
<slot> Platzhalter im Shadow-Baum, an dem die Kindknoten erscheinen, die im Dokument zwischen den Tags des Elements stehen.
.visually-hidden Blendet Text optisch aus, lässt ihn aber für Screenreader lesbar.

Aus diesem Blog: