Style scoping
A component's <style> is scoped to that component: its rules match the component's own markup, in the light DOM. This is the styling half of fixing Web Components: the scoping authors actually wanted, with the cascade and theming intact.
The scope: what a rule matches
The rules in a component's <style> match the component's region: its root element and the markup inside it, down to the boundaries below, and nowhere else in the document. A bare type or class selector needs no qualification, and every combinator works normally inside the region: descendant (a b), child (a > b), sibling (a + b, a ~ b), and :has().
<template component="x-card">
<defs>…</defs>
<article>
<h2>…</h2> <!-- in scope -->
<p class="lead">…</p> <!-- in scope -->
<slot></slot> <!-- projected content: out of scope unless :slotted() -->
<x-badge>New</x-badge> <!-- nested component: out of scope, root included -->
</article>
<style>
:host { padding: 1rem; } /* the root */
h2 { margin: 0; } /* descendant, matches */
:host > .lead { color: gray; } /* child of the root, matches */
</style>
</template>Where the scope ends
A component's subtree can contain markup that belongs to others. Two boundaries close the region, so it is a range: a root and a lower limit.
| Boundary | Rule |
|---|---|
| Nested components | A nested component is its own scope, root included. The enclosing component's rules must not match the nested component's root or anything inside it. The enclosing component lays its children out from its own markup (gap, grid, and flex on its container); a consumer that needs to adjust one child's box puts its own class on that invocation. |
| Projected content | Content a consumer passes through a <slot> belongs to the consumer (see Components). The component's rules must not match projected nodes unless it opts in with :slotted(). |
Content a component moves elsewhere with <portal> stays logically owned by it, so it stays in the component's region: the component's rules keep matching it after the move, and the same two boundaries apply inside it.
<template component="x-menu">
<button>Options</button>
<portal to="body">
<ul class="list">…</ul> <!-- moved to body, still this component's markup -->
</portal>
<style>
.list { position: fixed; padding: 0.25rem; } /* still matches after the move */
</style>
</template>Scoping decides where a component's rules apply. It does not protect markup from other code: nodes another script inserts inside a component's region are styled like the rest of that region.
Projected content is the consumer's
Markup a component writes inside another component's invocation is still its own markup. It renders below the receiving component's root, but it stays in the writing component's region: that component's rules match it there, at any depth, with the same two boundaries inside it. The receiving component's ordinary rules do not match it; its :slotted() rules do, and lose ties to the consumer's own.
<template component="x-page">
<main>
<x-frame>
<span class="menu" slot="menu">Menu</span> <!-- x-page's markup, inside x-frame's root -->
<x-badge>New</x-badge> <!-- nested component: out of x-page's scope -->
</x-frame>
</main>
<style>
.menu { color: red; } /* matches the projected span */
</style>
</template>Combinators relate that markup to the rest of what the component projects with it, as .menu .label finds a label inside the menu; they do not pass through the receiving component's markup in between. A rule that needs context above the receiving component names the root, as in :host .menu.
This is how Shadow DOM treats a host's light-DOM children: the tree they are written in styles them, and the shadow tree that renders them reaches them only through ::slotted().5 Each element answers to the stylesheet of the component that wrote it, wherever a slot places it.
The root: :host
:host selects the component's own root. It is the only way to select the root; :scope is not part of the authoring syntax.
A rule may be conditioned on context outside the component by placing that context before :host: an ancestor anywhere in the document, such as a theme class on the root element, or another component, named by its tag. A selector that does not mention :host is relative to the component, so .dark .label looks for a .dark inside it. Either way, the element a rule styles is always inside the component's region; only the condition reaches outside.
<style>
:host .label { color: var(--x-card-text, CanvasText); }
.dark :host .label { color: var(--x-card-text, Canvas); } /* .dark anywhere above the component */
x-sidebar :host { inline-size: 100%; } /* inside an x-sidebar */
.dark .label { … } /* a .dark inside this component */
</style>Page-wide styling (the <body>, other components, or unrelated elements) belongs in the page's stylesheets, or in a stylesheet a package ships alongside its components for the page to include. At-rules that are document-wide in CSS, such as @font-face, @property, and @keyframes names, keep that meaning inside a component's <style>.
Styling by props and state
Use :host([prop]) for declared props, and :state() for mutable or computed state. Both test resolved values, including defaults, and neither requires reflected DOM attributes.
A prop is a caller-supplied part of the public interface, while state belongs to the component. Keeping the selector spellings separate makes that distinction visible in the stylesheet, the way a native control separates input[type="checkbox"] from input:checked:
<template component="x-container">
<defs>
<prop name="measure" type="keyword" values="narrow, normal, wide" default="normal">Maximum line length.</prop>
</defs>
<div><slot></slot></div>
<style>
:host {
display: block;
margin-inline: auto;
max-inline-size: var(--x-container-measure, 65ch);
}
:host([measure="narrow"]) { max-inline-size: var(--x-container-measure, 45ch); }
:host([measure="wide"]) { max-inline-size: var(--x-container-measure, 80ch); }
</style>
</template>| Test | Matches while |
|---|---|
:host([name="value"]) | prop name resolves to value, including when value is the declared default |
:host([name]) | prop name is truthy |
:host([a="x"][b]) | every test holds |
A test inside :host(...) names a declared prop. The selected value must be a string, number, boolean, or keyword union. Only equality and presence are supported; other attribute operators and structured values are diagnostics.
State: :state()
:state() tests a component's state directly, so a component styles what its controller changes:
<template component="x-disclosure">
<defs>
<prop name="summary" type="string" default="">Visible heading.</prop>
<state type="boolean" name="open" value="false"></state>
</defs>
<details>…</details>
<style>
:host(:state(open)) .marker { rotate: 90deg; }
:host([summary=""]) .marker { display: none; }
</style>
</template>| Test | Matches a component root while |
|---|---|
:state(name) | state name is truthy |
:state(name=value) | state name resolves to value |
:state(name of x-tag) | state name of the x-tag component rendered at this element is truthy |
:state(name=value of x-tag) | the same, for a value |
namenames declared mutable or computed state. A prop in:state()is a diagnostic; props use:host([prop]).- The selected value must be a string, number, boolean, or keyword union. Only equality and presence are supported; structured values are diagnostics.
valueis written as in an attribute selector: an identifier as is, anything else quoted.:state(tone=warm)and:state(tone="warm")are the same test; a value that is not an identifier needs quotes: one that starts with a digit or with a hyphen and a digit ("2","2x","-1"), or that contains spaces or punctuation, as in:state(step="2").- Combine tests by repeating the pseudo-class:
:host(:state(open):state(tone=warm)). - Every declared state of a scalar type is selectable. Like the states of a built-in element, a component's state is visible to selectors automatically.
The boolean form is the native :state() pseudo-class, which tests the custom states of an element's ElementInternals. The value form and of extend it for the same reason host.state generalizes custom states from flags to values.
Another component's state is a condition
A component may test the state of a nested component or of a component in its projected content, in the same way it may already place context before :host. As everywhere in this chapter, the element a rule styles stays inside the component's region; only the condition reaches outside.
<template component="x-settings">
<ul>
<li><x-toggle></x-toggle><label>Notifications</label></li>
<li class="row"><x-item></x-item></li>
</ul>
<slot></slot>
<style>
x-toggle:state(on) + label { font-weight: bold; } /* a sibling of the toggle */
.row:has(x-item:state(selected)) { background: Highlight; } /* an ancestor of the item */
:slotted(li:has(x-item:state(selected))) { outline: 1px solid; }
</style>
</template>A rule whose subject is a nested component's root matches nothing, with or without :state(): x-toggle:state(on) { color: red; } does not restyle the toggle. An implementation should report such a rule as a diagnostic. The toggle styles itself with :host(:state(on)), or reads a custom property the caller sets.
Which component's state
An element can be the root of more than one component: a component whose markup root is another component shares its root element with that component. :state(name) without of resolves as follows:
- Inside
:host(...), it tests the state of the component the stylesheet belongs to. - Elsewhere, it matches if any component rendered at that element has state
namethat satisfies the test.
of x-tag limits the test to one of those components. It is needed only where two components at the same element declare the same state name. A component tag before the pseudo-class does not limit it: x-base:state(open) tests that the element is an x-base and, separately, that some component at it has open, as with any compound selector.
:host([prop]) sees the prop's resolved value, including its default; :state() sees mutable or computed state. Styling is independent of whether an implementation reflects a value as a data-* attribute.
A rendered component root is an ordinary element, not a custom element, so it has no ElementInternals today. This chapter defines :state() matching for component roots by their declared state. Whether the CSS Working Group adopts a value form of :state(), and with what syntax, is open; this proposal keeps the boolean form identical to native so only the extensions would need to change.
Styling projected content: :slotted()
When a component wants to style what a consumer projects, such as a form control skinning its <input> or a prose component setting rhythm for headings and lists, it opts in with :slotted():
<template component="x-prose">
<defs><prop name="compact" type="boolean" default="false"></prop></defs>
<article><slot></slot></article>
<style>
:slotted(h2) { margin-block: 1.5em 0.5em; }
:slotted(ul li) { margin-block: 0.25em; }
:slotted(p) { & + p { margin-block-start: 1em; } }
:host([compact]) :slotted(*) { margin-block: 0; }
</style>
</template>:slotted(sel)matches projected content at any depth: a projected node, or any node inside one, that matchessel.- The argument is a full selector, and nested rules work inside a
:slotted()rule. - Matching stops at any nested component.
:slotted()rules are a low-specificity baseline: a consumer's own rules on their projected nodes win ties, so a component sets a default look that consumers refine.:slotted(*) { all: unset }is the idiomatic clean slate.
Shadow DOM's ::slotted() accepts only a compound selector on top-level nodes, a restriction adopted because matching from a shadow tree into light-DOM descendants was costly; authors have asked for years to lift it.5 Here, projected content stays in one document and is matched like any other markup, so the restriction has no reason to exist. A component ported from Shadow DOM rewrites ::slotted(x) as :slotted(x).
Customization: custom properties
A component's customizable surface is its custom properties. The component reads each one with its default, var(--x-button-radius, 6px), and an application or an enclosing component sets it on any ancestor, or on the invocation itself through class or style. Custom properties inherit, so they reach the component across every boundary without selecting anything inside it.
A component styles another component only through that component's custom properties. A selector into someone else's private structure would couple the caller to it and break when that structure is refactored, so this proposal leaves out :deep() by design. A component's styling contract is exactly the custom properties it exposes.
Validity selectors
:valid, :invalid, and :user-invalid keep their native meaning. Scoping does not broaden them to ordinary elements. A component that needs form participation uses a native control as its root or inside its markup.
Inheritance still crosses
Scoping constrains selector matching; the cascade works as usual. Inherited properties (color, font, and custom properties such as design tokens) flow across every boundary above, into nested components and projected content alike, exactly as they do in ordinary light-DOM HTML. Authors get encapsulation of their own selectors while global theming, shared tokens, and the cascade keep working.
Vue and Svelte scoped styles, and Angular ViewEncapsulation.Emulated as the shipped default,3 won in practice over Shadow DOM because most authors want scoping (my selectors do not leak) and not isolation (nothing gets in or out). Shadow DOM, and Lit built on it, forces both.4 HTML Next makes scoping the default and treats isolation as the special case.
Compared with Vue and Svelte scoped styles
If you have written Vue's <style scoped> or a Svelte component's <style>, most of what you know carries over: a component's selectors match its own markup, and inherited properties and custom properties still cross into everything it renders. Four things work differently. Each keeps an element answerable to one stylesheet, its own component's, so refactoring one component never silently changes what another component's rules match.
The difference you meet first is a parent sizing a child. In Vue, a parent's scoped rules also match a child component's root, so this works:
<!-- Vue: the parent's scoped rule reaches the child's root -->
<template><div class="toolbar"><SearchBox class="search" /></div></template>
<style scoped>.search { flex: 1; }</style>In HTML Next the nested component is out of the parent's scope, root included, so the same rule matches nothing. Lay the child out from your own markup, or style it through the class you put on its invocation from a stylesheet that owns that class:
<template component="x-toolbar">
<div class="toolbar"><x-search-box></x-search-box></div>
<style>
.toolbar { display: grid; grid-template-columns: 1fr auto; } /* the parent lays out its own container */
</style>
</template>| HTML Next | Vue <style scoped> | Svelte | |
|---|---|---|---|
| A nested component's root | Out of scope. Lay it out from your own markup, or give the invocation a class | In scope: a child's root takes both the parent's scoped rules and its own | Out of scope |
| Inside a nested component | Through the component's custom properties | :deep() reaches in | :global() reaches in |
| The component's own root | :host, :host([prop]) for resolved props, and :host(:state(name)) for mutable or computed state | A class the author puts on the root element | A class the author puts on an element; a component may have several top-level elements |
| Content passed in through a slot | Opt in with :slotted(): a full selector, matching at any depth, stopping at nested components, and losing ties to the consumer's own rules | Opt in with :slotted() | Not matched; :global() reaches it |
Why HTML Next draws the lines this way:
- One owner per element. Vue lets a child's root answer to two stylesheets as a convenience for layout. The cost is that a child renaming or restructuring its root changes what the parent's rules hit, with no error. Here a parent's rules never match another component's elements, so that coupling cannot form.
- A public surface instead of a way in.
:deep()and:global()let a caller depend on another component's private markup. HTML Next gives components custom properties as their styling contract. - State without attributes. Vue and Svelte style a component's state through classes or attributes the component writes on itself.
:host([prop])and:state()test resolved props and state directly, defaults included, so nothing is written just so a stylesheet can see it. - Defined by the platform. The region, a root with a lower limit at nested components and projected content, is the shape of CSS
@scopewith its range form. An implementation may still rewrite selectors, for example when it compiles to Vue, but what matches is what this page defines.
Porting a component: move a parent's rules for a child's root into layout on the parent's own container, or into a class on the invocation; turn each :deep() into a custom property the child reads; replace classes toggled for prop styling with :host([prop]), and state styling with :host(:state(name)). For how a lowered component looks in the page, see Rendered form.
Isolation: opt-in, later
Full isolation via a real shadow boundary may be requested explicitly. An upstream proposal would specify it as an extension of Declarative Shadow DOM driven by the template, closing the styling and form-participation gaps that kept authors away from Shadow DOM.
Open issues
- Declared custom properties. Whether a definition declares its public custom properties in
<defs>, with a type and a default like a prop, so the customization surface is documented, typed, and checkable.
This page defines only the behaviour. How an implementation produces it is documented with that implementation. The model maps directly onto CSS @scope, whose range form (@scope (root) to (limit)) expresses the root-plus-lower-limit region above. A state test lowers to a token on a per-component data-<tag>-state attribute, written only for the names some stylesheet in the component graph tests.
References
- CSS Cascading and Inheritance Level 6, @scope (a style rule scoped to a subtree, with an optional lower limit forming a range).
- WHATWG HTML, Declarative Shadow DOM (the opt-in isolation path).
- Scoped-style precedent: Angular ViewEncapsulation.Emulated, its shipped default, is attribute-hash scoping with no shadow boundary; Vue
<style scoped>and Svelte scoped styles are the same scoping-not-isolation approach. - The isolation contrast authors avoided: Shadow DOM and Lit component styles impose a real shadow boundary that also blocks shared theming and the cascade.
- CSS Scoping,
:hostand::slotted(); the long-standing request to let::slotted()take complex selectors, csswg-drafts #2425, and to make it a combinator, #7922. - CSS Cascading and Inheritance Level 5,
@importand processing stylesheet imports (import occurrences retain their CSS meaning independently of resource-fetch caching); CSS Namespaces, scope (namespace declarations apply only to their containing stylesheet). - CSS Object Model, replacing stylesheet rules (constructed-sheet replacement removes
@importrules).