CSS @scope: innkapsling av stiler uten BEM og CSS Modules
Et produktkort har et bilde som skal beskjæres pent til forholdet 4:3. Du skriver den enklest mulige regelen: .card img { aspect-ratio: 4 / 3; object-fit: cover; }. Den fungerer – helt til noen setter inn en komponent med forfatterlinje i kortet. Den runde 32×32-avataren blir plutselig et 4:3-rektangel, fordi .card img ikke aner at akkurat dette bildet tilhører en helt annen komponent. En etterkommerselektor treffer alle etterkommere – på hvilken som helst dybde, i hvilken som helst nøstet komponent.
De siste femten årene har svaret på dette problemet vært å omgå kaskaden i stedet for å bruke den. BEM gjør .card img om til .card__image og krever at du legger klassen på hvert element for hånd – regelen rekker nøyaktig så langt som noen husket å merke elementene. CSS Modules gjør det samme automatisk: .image blir til .Card_image__x7f2a, så en navnekollisjon blir fysisk umulig. Begge tilnærmingene fungerer. Begge koker ned til det samme: Siden en selektor ikke kan stoppe ved grensen til en komponent, må du sørge for at ingen andre elementer har samme navn.
@scope snur denne retningen. I stedet for unike navn gir den selektoren noe CSS aldri har hatt: en eksplisitt grense – og det fra to sider.
To grenser, og BEM gir deg bare én
Problemet med .card img har to halvdeler, og det lønner seg å skille dem, fordi de fleste løsninger bare håndterer én.
Øvre grense – regelen skal bare gjelde inne i kortet, ikke alle bilder på siden. Det klarte den vanlige etterkommerselektoren allerede: .card img rører ikke bildet i sidens topptekst.
Nedre grense – regelen skal slutte å gjelde der en annen komponent nøstet inne i kortet begynner. Det kan en etterkommerselektor ikke uttrykke på noen måte, og det er nettopp her hele avatar-feilen ligger. BEM og CSS Modules løser ikke den nedre grensen – de omgår den, for hvis avataren har klassen .author__avatar i stedet for .card__image, har kortets regel rett og slett ingenting å treffe. Men når innholdet i kortet kommer fra et CMS som rå HTML, fra markdown eller som children sendt inn av noen andre, kan du ikke legge til klassen – og du er tilbake der du startet.
@scope navngir begge grensene direkte:
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;}}
Selektoren i den første parentesen er scope-roten (eng. scope root) – reglene inni gjelder bare elementer innenfor .card. Selektoren etter to er scope-grensen (eng. scope limit) – elementet .card__slot og alt inni det er utenfor rekkevidden. Produktbildet får sitt 4:3, avataren i sporet forblir urørt, og ingen trengte å legge en eneste klasse på selve bildet.
Syntaksen: rot, grense og «smultringen»
Et slikt scope med hull i midten kalles på folkemunne donut scope – stilen dekker ringen mellom roten og grensen, men ikke det som er inni smultringen. Noen detaljer som er lette å overse:
:scopeer selve roten. Inne i@scope (.card)peker pseudoklassen:scopepå elementet.card. Dermed kan stilene for beholderen og innholdet ligge i én blokk, uten å gjenta klassenavnet.- Vanlige selektorer inni er relative.
imgi en@scope (.card)-blokk betyr «et bilde inne i roten», ikke «hvilket som helst bilde på siden». Du trenger ikke skrive:scope img, men du kan – og om et øyeblikk viser det seg at forskjellen ikke er kosmetisk. - Også grensen er relativ til roten. Selektoren etter
tomatches bare blant rotens etterkommere, så selve roten blir aldri behandlet som en grense.
Den siste egenskapen åpner for et mønster som erstatter en hel navnekonvensjon med én regel: hver komponent merker roten sin med samme attributt, og hver komponent stopper ved den første andre komponenten den møter.
css
/* Kortet styler alt inni seg – helt til den førstenøstede komponenten, uansett hvilken komponent det er */@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="Skrivebordslampe"><p>Produktbeskrivelse…</p><div data-component="author"><img src="/avatar.jpg" alt=""><p>Kari Nordmann</p></div></article>
Kortets regler slutter ved [data-component="author"], forfatterens regler begynner der. Begge komponentene bruker nakne img og p – og likevel kommer ingen av dem i veien for den andre.
Nærhet: et nytt kriterium i kaskaden
Så langt ser @scope ut som en mer praktisk måte å skrive en etterkommerselektor med et unntak på. Men den tar også med seg noe vanlige selektorer ikke har i det hele tatt: scope-nærhet (eng. scope proximity). Når to regler fra ulike scopes konkurrerer om samme element, vinner den som har roten nærmest i DOM-treet.
Det klassiske eksempelet er nøstede temaer. Et panel med lyst tema inne på en side med mørkt tema:
css
/* Uten @scope */.theme-light a { color: #0b4fa8; }.theme-dark a { color: #8ab4ff; }
html
<body class="theme-dark"><aside class="theme-light"><a href="/vilkar">Vilkår</a></aside></body>
Lenken i det lyse panelet får den lyseblå fargen som er ment for mørk bakgrunn – og blir nesten uleselig. Begge selektorene har identisk spesifisitet (én klasse, ett element), så rekkefølgen i filen avgjør, og .theme-dark a står lenger ned. Selektoren vet ikke at .theme-light er nærmere lenken enn .theme-dark; den vet bare at begge er et sted over den. Å bytte rekkefølgen i filen fikser dette tilfellet og ødelegger det motsatte – et mørkt panel på en lys side.
css
@scope (.theme-light) {a { color: #0b4fa8; }}@scope (.theme-dark) {a { color: #8ab4ff; }}
Nå sjekker lenken hvilken rot som er nærmest: .theme-light er én generasjon opp, .theme-dark to. Det lyse temaet vinner, uavhengig av rekkefølgen på reglene og hvor dypt du nøster temaene i hverandre.
Fellen: nærhet taper mot spesifisitet
Her tier de fleste introduksjoner til @scope, og noen fremstiller det til og med motsatt. I de tidlige utkastene til spesifikasjonen skulle nærhet avgjøre konflikter før spesifisitet. I versjonen som havnet i nettleserne er det omvendt – nærhet gjelder først etter spesifisitet, som en dommer ved uavgjort rett før rekkefølgen i kildekoden. Den fullstendige rekkefølgen av kaskadekriterier ser i dag slik ut:
- Origin og
!important - Kontekst (Shadow DOM)
- Stiler festet til elementet (attributtet
style) - Lag (
@layer) - Spesifisitet
- Scope-nærhet (
@scope) - Rekkefølge i kildekoden
I tillegg kommer en annen, mindre åpenbar detalj: scope-roten legger ikke til spesifisitet for reglene inni. img inne i @scope (.card) har spesifisiteten til en vanlig img – (0,0,1), ikke (0,1,1) som .card img. Til sammen gir dette en effekt som overrasker ved første migrering:
css
/* Global stil for artikkelinnhold – spesifisitet (0,1,1) */.prose img {border-radius: 4px;}/* Et kort innebygd i artikkelen – spesifisitet (0,0,1) */@scope (.card) {img {border-radius: 12px;}}/* Resultat: bildet i kortet får 4px. Spesifisiteten avgjør sakenfør nærhet i det hele tatt blir sammenlignet. */
Kortet er nærmere bildet enn .prose, men det spiller ingen rolle, fordi konflikten aldri når nærhetstrinnet. Du har tre utveier, hver med ulik effekt:
:scope img– pseudoklassen:scopeteller som en klasse, så spesifisiteten øker til (0,1,1). Uavgjort med.prose img, og uavgjort avgjøres av nærhet – der vinner kortet alltid, fordi en regel uten noe@scoperegnes som uendelig langt unna.& img– inne i@scopeoppfører&seg som:is(.card), så den legger til hele spesifisiteten til rotselektoren. Med en enkel rot blir effekten den samme; med en omfattende rot (#app .layout .card) kan den legge til langt mer enn du ønsket.@layer– hvis stilene for artikkelinnhold ligger i et lavere lag enn komponentene, har spesifisitet ingenting å si her. Det er den reneste løsningen hvis du allerede har lagarkitekturen fra artikkelen om@layer.
Den lave spesifisiteten til regler i @scope er for øvrig oftere en fordel enn en ulempe: en komponent stylet med nakne elementselektorer er lett å overstyre utenfra med én enkelt klasse. Du må bare vite at det er slik, før du begynner å feilsøke «hvorfor scopet mitt ikke fungerer».
@scope uten selektor: stiler limt til forelderen
Det finnes en tredje form, kanskje den mest undervurderte. En @scope-blokk uten noen selektor, plassert i et <style>-element inne i dokumentet, tar forelderen til dette <style>-elementet som rot.
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="/tilbud">Se tilbudet</a></section>)}
Ingen klassenavn, ikke noe byggeverktøy, ingen hashing – scopet bestemmes kun av hvor stilen står i treet. For små, selvstendige komponenter, HTML-øyer rendret fra et CMS eller prototyper er dette et svært praktisk mønster.
To forbehold før du gjør om hele prosjektet ditt. For det første rendres <style> for hver instans av komponenten – ti bannere på en side betyr ti kopier av de samme reglene i HTML-en. For det andre blir en <style> med propsene href og precedence i React 19 flyttet til <head> og deduplisert. For vanlige stiler er det en flott funksjon, men for @scope uten selektor er det en stille katastrofe: forelderen til <style> blir <head>, så scopet dekker et element uten noe å style. Uten disse to propsene rendrer React <style> nøyaktig der du plasserte det, og alt fungerer.
Hva @scope ikke er: det er ikke Shadow DOM
Det er lett å forveksle @scope med innkapslingen Shadow DOM gir, og forskjellen er grunnleggende. Shadow DOM er en grense i begge retninger: stiler utenfra kommer ikke inn, stiler innenfra kommer ikke ut. @scope begrenser bare én retning – hvor langt dine regler rekker. Den globale .prose img fra eksempelet over kommer fortsatt uhindret inn i kortet, arv av color eller font-family fungerer som vanlig, og resetten i laget reset gjelder elementer i scopet akkurat som alle andre.
Det er en bevisst designbeslutning, ikke en begrensning. @scope er «myk» innkapsling for vanlig DOM: den beskytter resten av siden mot at stilene til en komponent lekker ut, men kutter ikke komponenten av fra designsystemet, typografien og CSS-variablene den skal arve. Trenger du hard isolasjon – en widget som bygges inn på andres nettsider, der du ikke kontrollerer den globale CSS-en – er det fortsatt en jobb for Shadow DOM.
Feller og god praksis
- Husk rekkefølgen: lag, spesifisitet, og først deretter nærhet. Nærhet avgjør bare uavgjorte tilfeller. Hvis et scope «ikke fungerer», sjekk først spesifisiteten til den konkurrerende regelen – i devtools ser du både laget og scopet ved hver regel.
@scopeog@layerutfyller hverandre, de konkurrerer ikke. Lag ordner arkitekturen i hele stilarket (reset, biblioteker, komponenter, verktøyklasser), scope ordner innsiden av én enkelt komponent. Den naturlige kombinasjonen er@layer components { @scope (.card) { … } }.- Velg bevisst mellom
img,:scope imgog& img. En naken selektor gir lavest spesifisitet og enkel overstyring utenfra,:scopelegger til nøyaktig én klasse,&legger til hele rotens spesifisitet. - Den nedre grensen er stedet for slots og innhold utenfra. Overalt der en komponent tar imot
children, HTML fra et CMS eller en annen komponent, erstatterto (…)det å legge klasser på hvert element. - Vær obs på hoisting av stiler i React 19.
@scopeuten selektor krever at<style>blir værende der det er – ikke gi dethrefellerprecedence. - Nettleserstøtte. Chrome og Edge har støttet
@scopesiden versjon 118 (2023), Safari siden 17.4 (2024), og Firefox kom sist med i versjon 146 mot slutten av 2025. En eldre nettleser ignorerer hele blokken sammen med reglene inni – det er ikke grasiøs degradering, det er ingen stiler i det hele tatt. Må du støtte eldre versjoner, hold deg til klasser der det betyr noe.
Oppsummering
BEM og CSS Modules løser lekkasje av stiler ved å endre navn slik at selektoren ikke har noe å treffe utenfor komponenten. @scope løser det samme problemet der det oppstår – i selve selektoren – ved å gi den en øvre grense, en nedre grense og et nytt kriterium for å avgjøre konflikter: nærhet i DOM-treet. Husk de to tingene som overrasker oftest: scope-roten øker ikke spesifisiteten til reglene inni, og nærhet taper mot spesifisitet og kommer først til orde ved uavgjort. Vet du det, får du komponenter som skriver nakne img og p og likevel ikke kommer i veien for hverandre – uten en eneste klasse med dobbel understrek.