CSS @scope: enkapsulacja stylów bez BEM i CSS Modules
Karta produktu ma zdjęcie, które ma się ładnie przyciąć do proporcji 4:3. Piszesz najprostszą możliwą regułę: .card img { aspect-ratio: 4 / 3; object-fit: cover; }. Działa – do momentu, w którym ktoś wstawi do karty komponent z podpisem autora. Okrągły awatar 32×32 nagle robi się prostokątem 4:3, bo .card img nie ma pojęcia, że ten konkretny obrazek należy do zupełnie innego komponentu. Selektor potomka dopasowuje każdego potomka – na dowolnej głębokości, w dowolnym zagnieżdżonym komponencie.
Przez ostatnie piętnaście lat odpowiedzią na ten problem było obchodzenie kaskady, a nie korzystanie z niej. BEM zamienia .card img w .card__image i każe ręcznie dopisać klasę do każdego elementu – zasięg reguły ogranicza się do tego, co ktoś pamiętał oznaczyć. CSS Modules robią to samo automatycznie: .image zamienia się w .Card_image__x7f2a, więc kolizja nazw staje się fizycznie niemożliwa. Oba podejścia działają. Oba sprowadzają się do tego samego: skoro selektor nie potrafi zatrzymać się na granicy komponentu, trzeba sprawić, żeby żaden inny element nie miał tej samej nazwy.
@scope odwraca ten kierunek. Zamiast unikalnych nazw daje selektorowi coś, czego CSS nigdy nie miał: jawną granicę – i to z dwóch stron.
Dwie granice, z których BEM daje tylko jedną
Problem z .card img ma dwie połowy i warto je rozdzielić, bo większość rozwiązań radzi sobie tylko z jedną.
Górna granica – reguła ma działać tylko wewnątrz karty, nie na wszystkich obrazkach na stronie. To akurat umiał już zwykły selektor potomka: .card img nie dotknie obrazka w nagłówku strony.
Dolna granica – reguła ma przestać działać tam, gdzie zaczyna się inny komponent zagnieżdżony w karcie. Tego selektor potomka nie wyrazi w żaden sposób, i właśnie tutaj leży cały bug z awatarem. BEM i CSS Modules nie rozwiązują dolnej granicy – omijają ją, bo jeśli awatar ma klasę .author__avatar, a nie .card__image, reguła karty po prostu nie ma go jak dopasować. Ale gdy treść karty przychodzi z CMS-a jako surowy HTML, z markdownu albo jest children przekazanymi przez kogoś innego, klasy nie dopiszesz – i wracasz do punktu wyjścia.
@scope nazywa obie granice wprost:
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;}}
Selektor w pierwszym nawiasie to korzeń zasięgu (ang. scope root) – reguły w środku dotyczą tylko elementów wewnątrz .card. Selektor po to to granica zasięgu (ang. scope limit) – element .card__slot i wszystko, co w nim, jest już poza zasięgiem. Obrazek produktu dostaje proporcje 4:3, awatar wstawiony do slotu zostaje nietknięty, i nikt nie musiał dopisywać ani jednej klasy do samego obrazka.
Składnia: korzeń, granica i „pączek”
Taki zasięg z dziurą w środku nazywa się potocznie donut scope – styl obejmuje pierścień między korzeniem a granicą, ale nie to, co w środku pączka. Kilka szczegółów, które łatwo przeoczyć:
:scopeto sam korzeń. Wewnątrz@scope (.card)pseudo-klasa:scopewskazuje element.card. Dzięki temu styl kontenera i jego zawartości siedzi w jednym bloku, bez powtarzania nazwy klasy.- Zwykłe selektory w środku są względne.
imgw bloku@scope (.card)oznacza „obrazek wewnątrz korzenia”, a nie „dowolny obrazek na stronie”. Nie musisz pisać:scope img, choć możesz – i za chwilę okaże się, że to nie jest kosmetyczna różnica. - Granica też jest względna wobec korzenia. Selektor po
tojest dopasowywany tylko wśród potomków korzenia, więc sam korzeń nigdy nie zostanie potraktowany jak granica.
Ta ostatnia własność umożliwia wzorzec, który zastępuje całą konwencję nazewniczą jedną regułą: każdy komponent oznacza swój korzeń tym samym atrybutem, a każdy komponent zatrzymuje się na pierwszym napotkanym innym komponencie.
css
/* Karta stylizuje wszystko w środku – aż do pierwszegozagnieżdżonego komponentu, niezależnie od tego, jaki to komponent */@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="Lampa biurkowa"><p>Opis produktu…</p><div data-component="author"><img src="/avatar.jpg" alt=""><p>Anna Nowak</p></div></article>
Reguły karty kończą się na [data-component="author"], reguły autora zaczynają się na nim. Oba komponenty używają gołego img i p – a mimo to żaden nie wchodzi drugiemu w drogę.
Bliskość: nowe kryterium w kaskadzie
Do tej pory @scope wygląda jak wygodniejszy zapis selektora potomka z dodatkowym wyjątkiem. Ale wnosi też coś, czego zwykłe selektory nie mają w ogóle: bliskość zasięgu (ang. scope proximity). Gdy dwie reguły z różnych zasięgów walczą o ten sam element, wygrywa ta, której korzeń jest bliżej w drzewie DOM.
Klasyczny przykład to zagnieżdżone motywy. Panel w jasnym motywie wewnątrz strony w ciemnym motywie:
css
/* Bez @scope */.theme-light a { color: #0b4fa8; }.theme-dark a { color: #8ab4ff; }
html
<body class="theme-dark"><aside class="theme-light"><a href="/regulamin">Regulamin</a></aside></body>
Link w jasnym panelu dostaje jasnoniebieski kolor przeznaczony dla ciemnego tła – czyli staje się prawie nieczytelny. Oba selektory mają identyczną specyficzność (jedna klasa, jeden element), więc rozstrzyga kolejność w pliku, a .theme-dark a stoi niżej. Selektor nie wie, że .theme-light jest bliżej linku niż .theme-dark; wie tylko, że oba są gdzieś wyżej. Odwrócenie kolejności w pliku naprawi ten przypadek i zepsuje odwrotny – ciemny panel w jasnej stronie.
css
@scope (.theme-light) {a { color: #0b4fa8; }}@scope (.theme-dark) {a { color: #8ab4ff; }}
Teraz link sprawdza, który korzeń ma bliżej: .theme-light jest jedno pokolenie wyżej, .theme-dark dwa. Wygrywa jasny motyw, niezależnie od kolejności reguł i od tego, jak głęboko zagnieździsz motywy w sobie.
Pułapka: bliskość przegrywa ze specyficznością
Tutaj większość wprowadzeń do @scope coś przemilcza, a czasem wręcz podaje odwrotnie. W pierwszych szkicach specyfikacji bliskość miała rozstrzygać konflikty przed specyficznością. W wersji, która trafiła do przeglądarek, jest odwrotnie – bliskość działa dopiero po specyficzności, jako sędzia remisu tuż przed kolejnością w źródle. Pełna kolejność kryteriów kaskady wygląda dziś tak:
- Origin i
!important - Kontekst (Shadow DOM)
- Style przypięte do elementu (atrybut
style) - Warstwy (
@layer) - Specyficzność
- Bliskość zasięgu (
@scope) - Kolejność w źródle
Do tego dochodzi druga, mniej oczywista rzecz: korzeń zasięgu nie dodaje specyficzności regułom w środku. img wewnątrz @scope (.card) ma specyficzność zwykłego img – (0,0,1), a nie (0,1,1) jak .card img. Razem dają efekt, który zaskakuje przy pierwszej migracji:
css
/* Globalny styl treści artykułu – specyficzność (0,1,1) */.prose img {border-radius: 4px;}/* Karta osadzona w artykule – specyficzność (0,0,1) */@scope (.card) {img {border-radius: 12px;}}/* Wynik: obrazek w karcie ma 4px. Specyficzność rozstrzyga sprawę,zanim w ogóle dojdzie do porównania bliskości. */
Karta jest bliżej obrazka niż .prose, ale to nie ma znaczenia, bo do etapu bliskości starcie nigdy nie dochodzi. Masz trzy wyjścia, każde z innym skutkiem:
:scope img– pseudo-klasa:scopeliczy się jak klasa, więc specyficzność rośnie do (0,1,1). Remis z.prose img, a remis rozstrzyga bliskość – i tu karta wygrywa zawsze, bo reguła bez żadnego@scopejest traktowana jak nieskończenie daleka.& img–&wewnątrz@scopedziała jak:is(.card), więc dodaje pełną specyficzność selektora korzenia. Przy prostym korzeniu efekt jest ten sam, przy rozbudowanym (#app .layout .card) potrafi dodać dużo więcej, niż chciałeś.@layer– jeśli style treści artykułu leżą w niższej warstwie niż komponenty, specyficzność w ogóle nie ma tu nic do powiedzenia. To najczystsze rozwiązanie, jeśli masz już architekturę warstw z artykułu o@layer.
Niska specyficzność reguł w @scope to zresztą częściej zaleta niż wada: komponent stylizowany gołymi selektorami elementów łatwo nadpisać z zewnątrz pojedynczą klasą. Trzeba tylko wiedzieć, że tak jest, zanim zacznie się debugować „dlaczego mój zasięg nie działa”.
@scope bez selektora: styl przyklejony do rodzica
Jest jeszcze trzecia forma, chyba najbardziej niedoceniana. Blok @scope bez żadnego selektora, umieszczony w elemencie <style> wewnątrz dokumentu, przyjmuje za korzeń rodzica tego elementu <style>.
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="/promocje">Zobacz ofertę</a></section>)}
Żadnych nazw klas, żadnego narzędzia budującego, żadnego haszowania – zasięg wyznacza samo położenie stylu w drzewie. Dla małych, samodzielnych komponentów, wysp HTML-a renderowanych z CMS-a albo prototypów to bardzo wygodny wzorzec.
Dwie uwagi, zanim zamienisz na niego cały projekt. Po pierwsze, <style> renderuje się przy każdej instancji komponentu – dziesięć banerów na stronie to dziesięć kopii tych samych reguł w HTML-u. Po drugie, w React 19 <style> z propsami href i precedence jest przenoszony do <head> i deduplikowany. Dla zwykłych stylów to świetna funkcja, ale dla @scope bez selektora oznacza katastrofę po cichu: rodzicem <style> staje się <head>, więc zasięg obejmuje element, w którym nie ma niczego do ostylowania. Bez tych dwóch propsów React renderuje <style> dokładnie tam, gdzie go umieściłeś, i wszystko działa.
Czym @scope nie jest: to nie Shadow DOM
Łatwo pomylić @scope z enkapsulacją, jaką daje Shadow DOM, a różnica jest zasadnicza. Shadow DOM to granica w obie strony: style z zewnątrz nie wchodzą do środka, style ze środka nie wychodzą na zewnątrz. @scope ogranicza tylko jeden kierunek – to, dokąd sięgają twoje reguły. Globalny .prose img z przykładu wyżej dalej wchodzi do karty bez przeszkód, dziedziczenie color czy font-family działa normalnie, a reset z warstwy reset dotyczy elementów w zasięgu tak samo jak wszystkich innych.
To świadoma decyzja projektowa, nie ograniczenie. @scope jest „miękką” enkapsulacją dla zwykłego DOM-u: chroni świat przed wyciekiem stylów komponentu, ale nie odcina komponentu od design systemu, typografii i zmiennych CSS, które ma odziedziczyć. Jeśli potrzebujesz twardej izolacji – widżet osadzany na cudzych stronach, gdzie nie kontrolujesz globalnego CSS-a – to wciąż zadanie dla Shadow DOM.
Pułapki i dobre praktyki
- Pamiętaj o kolejności: warstwa, specyficzność, dopiero bliskość. Bliskość rozstrzyga tylko remisy. Jeśli zasięg „nie działa”, najpierw sprawdź specyficzność konkurencyjnej reguły – w devtools przy regule widać zarówno jej warstwę, jak i zasięg.
@scopei@layeruzupełniają się, nie konkurują. Warstwy porządkują architekturę całego arkusza (reset, biblioteki, komponenty, narzędziówka), zasięg porządkuje wnętrze pojedynczego komponentu. Naturalne połączenie to@layer components { @scope (.card) { … } }.- Wybieraj świadomie między
img,:scope imgi& img. Goły selektor daje najniższą specyficzność i łatwe nadpisywanie z zewnątrz,:scopedodaje dokładnie jedną klasę,&dodaje całą specyficzność korzenia. - Granica dolna to miejsce na sloty i treść z zewnątrz. Wszędzie tam, gdzie komponent przyjmuje
children, HTML z CMS-a albo inny komponent,to (…)zastępuje dopisywanie klas do każdego elementu. - Uważaj na hoisting stylów w React 19.
@scopebez selektora wymaga, żeby<style>został w miejscu – nie dodawaj muhrefaniprecedence. - Wsparcie przeglądarek. Chrome i Edge obsługują
@scopeod wersji 118 (2023), Safari od 17.4 (2024), a Firefox dołączył jako ostatni w wersji 146 pod koniec 2025 roku. Starsza przeglądarka zignoruje cały blok razem z regułami w środku – to nie jest stopniowa degradacja, tylko brak stylów. Jeśli musisz wspierać starsze wersje, zostań przy klasach w krytycznych miejscach.
Podsumowanie
BEM i CSS Modules rozwiązują wyciek stylów, zmieniając nazwy tak, żeby selektor nie miał czego dopasować poza komponentem. @scope rozwiązuje ten sam problem tam, gdzie powstaje – w samym selektorze – dając mu górną granicę, dolną granicę i nowe kryterium rozstrzygania konfliktów, bliskość w drzewie DOM. Zapamiętaj przy tym dwie rzeczy, które najczęściej zaskakują: korzeń zasięgu nie podbija specyficzności reguł w środku, a bliskość przegrywa ze specyficznością i dochodzi do głosu dopiero przy remisie. Kto to wie, dostaje komponenty, które piszą gołe img i p i mimo to nie wchodzą sobie w drogę – bez jednej klasy z podwójnym podkreślnikiem.