CSS @scope: Styles kapseln ohne BEM und CSS Modules
Eine Produktkarte hat ein Foto, das sauber auf ein Seitenverhältnis von 4:3 zugeschnitten werden soll. Du schreibst die einfachste mögliche Regel: .card img { aspect-ratio: 4 / 3; object-fit: cover; }. Sie funktioniert – bis jemand eine Autorenzeile als Komponente in die Karte einsetzt. Der runde 32×32-Avatar wird plötzlich zum 4:3-Rechteck, weil .card img keine Ahnung hat, dass genau dieses Bild zu einer ganz anderen Komponente gehört. Ein Nachfahrenselektor trifft jeden Nachfahren – in beliebiger Tiefe, in jeder verschachtelten Komponente.
In den letzten fünfzehn Jahren lautete die Antwort auf dieses Problem, die Kaskade zu umgehen, statt sie zu nutzen. BEM macht aus .card img ein .card__image und verlangt, die Klasse jedem Element von Hand zu geben – die Regel reicht genau so weit, wie jemand daran gedacht hat, Elemente zu markieren. CSS Modules machen dasselbe automatisch: Aus .image wird .Card_image__x7f2a, eine Namenskollision wird also physisch unmöglich. Beide Ansätze funktionieren. Beide laufen auf dasselbe hinaus: Wenn ein Selektor nicht an der Grenze einer Komponente stehen bleiben kann, muss man dafür sorgen, dass kein anderes Element denselben Namen trägt.
@scope kehrt diese Richtung um. Statt eindeutiger Namen gibt es dem Selektor etwas, das CSS nie hatte: eine explizite Grenze – und zwar von zwei Seiten.
Zwei Grenzen, von denen BEM nur eine liefert
Das Problem mit .card img hat zwei Hälften, und es lohnt sich, sie zu trennen, weil die meisten Lösungen nur mit einer zurechtkommen.
Die obere Grenze – die Regel soll nur innerhalb der Karte gelten, nicht für alle Bilder der Seite. Das konnte der gewöhnliche Nachfahrenselektor schon immer: .card img berührt das Bild im Seitenkopf nicht.
Die untere Grenze – die Regel soll dort aufhören, wo eine andere, in die Karte verschachtelte Komponente beginnt. Das kann ein Nachfahrenselektor auf keine Weise ausdrücken, und genau hier steckt der ganze Avatar-Bug. BEM und CSS Modules lösen die untere Grenze nicht – sie umgehen sie: Hat der Avatar die Klasse .author__avatar statt .card__image, kann die Regel der Karte ihn schlicht nicht treffen. Kommt der Inhalt der Karte aber als rohes HTML aus einem CMS, aus Markdown oder als children, die jemand anderes übergibt, kannst du keine Klasse ergänzen – und bist wieder am Anfang.
@scope benennt beide Grenzen direkt:
css
@scope (.card) to (.card__slot) {:scope {border: 1px solid #e0e0e0;border-radius: 16px;overflow: hidden;}img {width: 100%;aspect-ratio: 4 / 3;object-fit: cover;}}
Der Selektor in der ersten Klammer ist die Scope-Wurzel (engl. scope root) – die Regeln darin gelten nur für Elemente innerhalb von .card. Der Selektor nach to ist die Scope-Grenze (engl. scope limit) – das Element .card__slot und alles darin liegen bereits außerhalb. Das Produktbild bekommt sein 4:3, der Avatar im Slot bleibt unberührt, und niemand musste dem Bild selbst auch nur eine Klasse geben.
Die Syntax: Wurzel, Grenze und der „Donut“
Ein solcher Scope mit einem Loch in der Mitte heißt umgangssprachlich Donut Scope – der Stil umfasst den Ring zwischen Wurzel und Grenze, aber nicht das, was im Inneren des Donuts liegt. Ein paar Details, die man leicht übersieht:
:scopeist die Wurzel selbst. Innerhalb von@scope (.card)verweist die Pseudoklasse:scopeauf das Element.card. So stehen die Styles des Containers und seines Inhalts in einem Block, ohne den Klassennamen zu wiederholen.- Gewöhnliche Selektoren darin sind relativ.
imgin einem Block@scope (.card)bedeutet „ein Bild innerhalb der Wurzel“, nicht „irgendein Bild auf der Seite“. Du musst nicht:scope imgschreiben, kannst es aber – und gleich wird sich zeigen, dass der Unterschied nicht kosmetisch ist. - Auch die Grenze ist relativ zur Wurzel. Der Selektor nach
towird nur unter den Nachfahren der Wurzel gesucht, die Wurzel selbst wird also nie als Grenze behandelt.
Diese letzte Eigenschaft ermöglicht ein Muster, das eine ganze Namenskonvention durch eine einzige Regel ersetzt: Jede Komponente markiert ihre Wurzel mit demselben Attribut, und jede Komponente hört bei der ersten anderen Komponente auf, auf die sie trifft.
css
/* Die Karte stylt alles in ihrem Inneren – bis zur erstenverschachtelten Komponente, egal welche das ist */@scope ([data-component="card"]) to ([data-component]) {img { aspect-ratio: 4 / 3; object-fit: cover; }p { color: #444; line-height: 1.6; }}@scope ([data-component="author"]) to ([data-component]) {img { width: 32px; height: 32px; border-radius: 50%; }}
html
<article data-component="card"><img src="/product.jpg" alt="Schreibtischlampe"><p>Produktbeschreibung…</p><div data-component="author"><img src="/avatar.jpg" alt=""><p>Anna Schmidt</p></div></article>
Die Regeln der Karte enden bei [data-component="author"], die Regeln des Autors beginnen dort. Beide Komponenten verwenden nacktes img und p – und trotzdem kommt keine der anderen in die Quere.
Nähe: ein neues Kriterium in der Kaskade
Bis hierhin sieht @scope aus wie eine bequemere Schreibweise des Nachfahrenselektors mit einer Ausnahme. Es bringt aber auch etwas mit, das gewöhnliche Selektoren überhaupt nicht haben: Scope-Nähe (engl. scope proximity). Wenn zwei Regeln aus verschiedenen Scopes um dasselbe Element konkurrieren, gewinnt die, deren Wurzel im DOM-Baum näher liegt.
Das klassische Beispiel sind verschachtelte Themes. Ein Panel im hellen Theme innerhalb einer Seite im dunklen Theme:
css
/* Ohne @scope */.theme-light a { color: #0b4fa8; }.theme-dark a { color: #8ab4ff; }
html
<body class="theme-dark"><aside class="theme-light"><a href="/agb">AGB</a></aside></body>
Der Link im hellen Panel bekommt das Hellblau, das für einen dunklen Hintergrund gedacht ist – und wird damit fast unlesbar. Beide Selektoren haben dieselbe Spezifität (eine Klasse, ein Element), also entscheidet die Reihenfolge in der Datei, und .theme-dark a steht weiter unten. Der Selektor weiß nicht, dass .theme-light dem Link näher ist als .theme-dark; er weiß nur, dass beide irgendwo darüber liegen. Die Reihenfolge in der Datei umzudrehen, repariert diesen Fall und bricht den umgekehrten – ein dunkles Panel auf einer hellen Seite.
css
@scope (.theme-light) {a { color: #0b4fa8; }}@scope (.theme-dark) {a { color: #8ab4ff; }}
Jetzt prüft der Link, welche Wurzel näher ist: .theme-light liegt eine Generation höher, .theme-dark zwei. Das helle Theme gewinnt – unabhängig von der Reihenfolge der Regeln und davon, wie tief du die Themes ineinander verschachtelst.
Die Falle: Nähe verliert gegen Spezifität
Hier verschweigen die meisten Einführungen in @scope etwas, manche stellen es sogar umgekehrt dar. In den ersten Entwürfen der Spezifikation sollte die Nähe Konflikte vor der Spezifität entscheiden. In der Version, die in den Browsern gelandet ist, ist es andersherum – die Nähe greift erst nach der Spezifität, als Schiedsrichter bei Gleichstand direkt vor der Reihenfolge im Quelltext. Die vollständige Reihenfolge der Kaskadenkriterien sieht heute so aus:
- Origin und
!important - Kontext (Shadow DOM)
- Am Element hängende Styles (Attribut
style) - Layer (
@layer) - Spezifität
- Scope-Nähe (
@scope) - Reihenfolge im Quelltext
Dazu kommt ein zweites, weniger offensichtliches Detail: Die Scope-Wurzel erhöht die Spezifität der Regeln darin nicht. img innerhalb von @scope (.card) hat die Spezifität eines gewöhnlichen img – (0,0,1), nicht (0,1,1) wie .card img. Zusammen ergibt das einen Effekt, der bei der ersten Migration überrascht:
css
/* Globaler Stil für Artikelinhalte – Spezifität (0,1,1) */.prose img {border-radius: 4px;}/* Eine im Artikel eingebettete Karte – Spezifität (0,0,1) */@scope (.card) {img {border-radius: 12px;}}/* Ergebnis: Das Bild in der Karte hat 4px. Die Spezifität entscheidet,bevor es überhaupt zum Vergleich der Nähe kommt. */
Die Karte liegt näher am Bild als .prose, aber das spielt keine Rolle, weil der Konflikt die Stufe der Nähe nie erreicht. Du hast drei Auswege, jeder mit einer anderen Wirkung:
:scope img– die Pseudoklasse:scopezählt wie eine Klasse, die Spezifität steigt also auf (0,1,1). Gleichstand mit.prose img, und den entscheidet die Nähe – hier gewinnt die Karte immer, weil eine Regel ganz ohne@scopeals unendlich weit entfernt gilt.& img–&verhält sich innerhalb von@scopewie:is(.card)und addiert damit die volle Spezifität des Wurzelselektors. Bei einer einfachen Wurzel ist der Effekt derselbe, bei einer aufwendigen (#app .layout .card) kann er viel mehr addieren, als du wolltest.@layer– liegen die Styles der Artikelinhalte in einem niedrigeren Layer als die Komponenten, hat die Spezifität hier gar nichts mehr zu sagen. Das ist die sauberste Lösung, wenn du die Layer-Architektur aus dem Artikel über@layerschon hast.
Die niedrige Spezifität von Regeln in @scope ist übrigens öfter ein Vorteil als ein Nachteil: Eine Komponente, die mit nackten Elementselektoren gestylt ist, lässt sich von außen leicht mit einer einzigen Klasse überschreiben. Man muss es nur wissen, bevor man anfängt zu debuggen, „warum mein Scope nicht funktioniert“.
@scope ohne Selektor: Styles, die am Elternelement kleben
Es gibt noch eine dritte Form, wahrscheinlich die am meisten unterschätzte. Ein @scope-Block ganz ohne Selektor, in einem <style>-Element innerhalb des Dokuments, nimmt das Elternelement dieses <style> als Wurzel.
tsx
export function PromoBanner({ title }: { title: string }) {return (<section><style>{`@scope {:scope { padding: 24px; background: #fff4e5; border-radius: 12px; }h2 { font-size: 1.25rem; margin: 0 0 8px; }a { color: #b45309; font-weight: 600; }}`}</style><h2>{title}</h2><a href="/angebote">Zum Angebot</a></section>)}
Keine Klassennamen, kein Build-Tool, kein Hashing – den Scope bestimmt allein die Position des Styles im Baum. Für kleine, eigenständige Komponenten, HTML-Inseln aus einem CMS oder Prototypen ist das ein sehr bequemes Muster.
Zwei Hinweise, bevor du dein ganzes Projekt darauf umstellst. Erstens wird <style> bei jeder Instanz der Komponente gerendert – zehn Banner auf einer Seite sind zehn Kopien derselben Regeln im HTML. Zweitens wird in React 19 ein <style> mit den Props href und precedence in den <head> verschoben und dedupliziert. Für gewöhnliche Styles ist das eine großartige Funktion, für @scope ohne Selektor aber eine stille Katastrophe: Das Elternelement des <style> wird zum <head>, der Scope umfasst also ein Element, in dem es nichts zu stylen gibt. Ohne diese beiden Props rendert React das <style> genau dort, wo du es platziert hast, und alles funktioniert.
Was @scope nicht ist: kein Shadow DOM
Man verwechselt @scope leicht mit der Kapselung, die Shadow DOM bietet, und der Unterschied ist grundlegend. Shadow DOM ist eine Grenze in beide Richtungen: Styles von außen kommen nicht hinein, Styles von innen nicht hinaus. @scope begrenzt nur eine Richtung – wie weit deine Regeln reichen. Das globale .prose img aus dem Beispiel oben gelangt weiterhin ungehindert in die Karte, die Vererbung von color oder font-family funktioniert ganz normal, und der Reset aus dem Layer reset gilt für Elemente im Scope genauso wie für alle anderen.
Das ist eine bewusste Designentscheidung, keine Einschränkung. @scope ist eine „weiche“ Kapselung für das gewöhnliche DOM: Sie schützt den Rest der Seite davor, dass Styles einer Komponente auslaufen, schneidet die Komponente aber nicht vom Designsystem, der Typografie und den CSS-Variablen ab, die sie erben soll. Brauchst du harte Isolation – ein Widget, das auf fremden Seiten eingebettet wird, deren globales CSS du nicht kontrollierst –, ist das weiterhin ein Fall für Shadow DOM.
Fallen und Best Practices
- Merke dir die Reihenfolge: Layer, Spezifität, erst dann Nähe. Die Nähe entscheidet nur bei Gleichstand. Wenn ein Scope „nicht funktioniert“, prüfe zuerst die Spezifität der konkurrierenden Regel – in den DevTools siehst du bei jeder Regel sowohl ihren Layer als auch ihren Scope.
@scopeund@layerergänzen sich, statt zu konkurrieren. Layer ordnen die Architektur des gesamten Stylesheets (Reset, Bibliotheken, Komponenten, Utilities), der Scope ordnet das Innere einer einzelnen Komponente. Die natürliche Kombination ist@layer components { @scope (.card) { … } }.- Wähle bewusst zwischen
img,:scope imgund& img. Ein nackter Selektor liefert die niedrigste Spezifität und lässt sich leicht von außen überschreiben,:scopeaddiert genau eine Klasse,&die volle Spezifität der Wurzel. - Die untere Grenze ist der Platz für Slots und fremde Inhalte. Überall, wo eine Komponente
children, HTML aus einem CMS oder eine andere Komponente aufnimmt, ersetztto (…)das Ergänzen von Klassen an jedem Element. - Achte auf das Hoisting von Styles in React 19.
@scopeohne Selektor setzt voraus, dass<style>an Ort und Stelle bleibt – gib ihm keinhrefund keinprecedence. - Browser-Unterstützung. Chrome und Edge unterstützen
@scopeseit Version 118 (2023), Safari seit 17.4 (2024), und Firefox kam als Letzter mit Version 146 Ende 2025 dazu. Ein älterer Browser ignoriert den gesamten Block samt der Regeln darin – das ist keine sanfte Degradierung, sondern schlicht keine Styles. Musst du ältere Versionen unterstützen, bleib an kritischen Stellen bei Klassen.
Fazit
BEM und CSS Modules lösen das Auslaufen von Styles, indem sie Namen so ändern, dass der Selektor außerhalb der Komponente nichts mehr findet. @scope löst dasselbe Problem dort, wo es entsteht – im Selektor selbst –, und gibt ihm eine obere Grenze, eine untere Grenze und ein neues Kriterium zur Konfliktlösung: Nähe im DOM-Baum. Merke dir dabei die zwei Dinge, die am häufigsten überraschen: Die Scope-Wurzel erhöht die Spezifität der Regeln darin nicht, und die Nähe verliert gegen die Spezifität und kommt erst bei Gleichstand zum Zug. Wer das weiß, bekommt Komponenten, die nacktes img und p schreiben und sich trotzdem nicht in die Quere kommen – ohne eine einzige Klasse mit doppeltem Unterstrich.