§10 · Declarative HTML Components Level 1

Style scoping

A component's <style> is scoped to that component: its rules match the component's own markup and no further, without a shadow boundary. This is the styling half of fixing Web Components: the scoping authors actually wanted, without the cascade and theming losses that kept them away from Shadow DOM.

Level 1 · stable direction

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 is not the component's to style. Two boundaries close the region, so it is a range with a lower limit, not just a starting point.

BoundaryRule
Nested componentsA 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 contentContent a consumer passes through a <slot> belongs to the consumer, not the component (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, not a security boundary

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.

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, not a page theme */
</style>

Styling the page itself (its <body>, other components, or unrelated elements) is never a component's to do. Page-wide rules belong 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 :host-state([state]) for mutable or computed state. Both select the component's root using resolved values, including defaults. Their arguments use attribute-selector-shaped tests; they do not require 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:

<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>
TestMatches while
[name="value"]the selected prop or state name resolves to value, including when value is the declared default
[name]name is truthy
[a="x"][b]every test holds

A prop test inside :host(...) names a declared prop. A test inside :host-state(...) names mutable or computed state; a prop in :host-state(...) is a diagnostic. The selected value must be a string, number, boolean, or keyword union. Only equality and presence are supported for these declared-value tests; other attribute operators and structured values are diagnostics.

State uses the same tests through :host-state(), so a component styles what its controller changes without writing attributes for its own stylesheet:

<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>
Resolved values, not reflected attributes

:host([prop]) sees the prop's resolved value, including its default; :host-state([state]) sees mutable or computed state. Styling does not depend on whether an implementation reflects a value as a data-* attribute.

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 matches sel.
  • 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 without seizing control. :slotted(*) { all: unset } is the idiomatic clean slate.
Deeper than ::slotted()

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 never reaches into another component's internals. There is deliberately no :deep(): a selector into someone else's private structure couples the caller to it and breaks when that structure is refactored. What a component does not expose as a custom property is not part of its styling contract.

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, not the cascade. 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, which is the trade Shadow DOM refused to offer.

Selector scoping preserves the cascade

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 NextVue <style scoped>Svelte
A nested component's rootOut of scope. Lay it out from your own markup, or give the invocation a classIn scope: a child's root takes both the parent's scoped rules and its ownOut of scope
Inside a nested componentNever. There is no :deep(); use 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([state]) for mutable or computed stateA class the author puts on the root elementA class the author puts on an element; a component may have several top-level elements
Content passed in through a slotOpt in with :slotted(): a full selector, matching at any depth, stopping at nested components, and losing ties to the consumer's own rulesOpt 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 and no selector into someone else's internals.
  • State without attributes. Vue and Svelte style a component's state through classes or attributes the component writes on itself. :host([prop]) and :host-state([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 @scope with its range form, rather than a selector rewrite particular to one build tool. 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([state]). 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; it is never the default cost. A later Level specifies 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, rather than inheriting them.

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 instead of being discovered from the stylesheet.
How this is achieved is an implementation detail

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 a definition's own stylesheet tests.

References

  1. CSS Cascading and Inheritance Level 6, @scope (a style rule scoped to a subtree, with an optional lower limit forming a range).
  2. WHATWG HTML, Declarative Shadow DOM (the opt-in isolation path).
  3. 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.
  4. The isolation contrast authors avoided: Shadow DOM and Lit component styles impose a real shadow boundary that also blocks shared theming and the cascade.
  5. CSS Scoping, :host and ::slotted(); the long-standing request to let ::slotted() take complex selectors, csswg-drafts #2425, and to make it a combinator, #7922.

Declarative HTML Components Level 1: Style Scoping. Unofficial Editor's Draft · Stage 0. Published in the HTML Next collection by the Next Web Working Group; not a W3C or WHATWG deliverable. Version history.