A resource for design system and engineering teams
Shadow DOMtokenscoping rules.
Tokens flow in. Styles stay put.
Read the theme. Never redefine it.
The boundary is a filter, not a wall.
A shadow root stops selectors, not inheritance. Every custom property set on the document reaches every element inside every shadow root, and so do inherited properties such as color and font-family [S01, S02]. Class selectors, element selectors, utility classes and global resets do not. That is exactly the split a token system wants: the theme travels in as custom properties, and the component's own styles cannot leak out.
The split only holds if components follow a few rules. A component that redefines --ds-color-bg-brand on :host silently re-themes everything slotted into it. A literal fallback in var() hides a missing token sheet. :host-context() works in Chromium and nowhere else. A registered property with inherits: false stops at the first boundary. Each of these ships without an error, and each is a one-line check.
This guide gives 24 rules. The kit's checker runs 12 checks covering 15 of them, on plain CSS and on Lit css templates, reporting 17 errors and 1 warning on the failing example and none on the clean one or on the Field Guide 15 starter. The probe page measures the rest in whatever browser you open it in.
Practical guidance, not a standard. The checker is a focused heuristic, not a CSS parser; it reads the rules listed in Appendix A and nothing else. Firefox and Safari results come from MDN and browser-compat-data, not from the probe run. Prepared with AI assistance and edited by hand.
Start here
What crosses the boundary.
Measured by the kit's boundary-probe.html in Chromium 153. Open the file in each browser you support; every row sets up a real shadow root and reports what it saw.
| Crosses into the shadow root | Stops at the boundary |
|---|---|
Custom properties set on :root or any ancestor (P01) | Class and element selectors from the page (P03) |
Inherited properties: color, font-*, line-height (P02, P16) | Global resets: *, html, body rules (S01) |
color-scheme, so light-dark() resolves per theme (P15) | Registered properties with inherits: false (P06) |
@font-face names declared in the document (S01) | The page's @layer order (P13) |
Page rules on the host, which beat :host (P08) | @property rules declared inside the shadow root (P07) |
Row ids are the probe's. all: unset inside the shadow root leaves custom properties in place (P05), so a component reset never deletes the theme.
Building a new component
Sections 01 and 02, then copy the clean example's structure: three layers, private --_ state on :host, semantic tokens only.
Auditing an existing library
Run check-shadow-css.mjs over the components folder. It reads .css files and Lit css templates in .ts and .js.
Opening a styling API
Section 03: parts for layout tweaks, --<tag>-* properties for documented hooks, tokens for everything a theme changes.
Server rendering
Rule T22. Declarative shadow DOM inlines styles per instance; measure the cost before you promise SSR.
| Label | Meaning |
|---|---|
CHECKER | Enforced by check-shadow-css.mjs; the rule id (SD01 to SD12) is in the evidence line. |
PROBE | Measured by a row (P01 to P16) of boundary-probe.html. |
KIT TEST | Observed by the Field Guide 15 starter's browser tests. |
SPEC | Follows from a CSS specification or MDN, with no automated check in the kit. |
PRACTICE | A working method with a review signal rather than a hard check. |
Section 01
Tokens in: read the theme, never set it.
The document owns token values. Components read them through inheritance and keep their own state in private properties.
Suggested owners: Design-system lead + component owner
Load token values once, in the document
Custom properties inherit through every shadow boundary, so one token stylesheet on the page reaches every component. Importing tokens inside each shadow root duplicates them and breaks theme switching.
Evidence: P01 reads the
:rootvalue inside a shadow root. SD10 flags@importin component CSS.CSS Shadow: the flat tree is used for inheritance (S01); Lit styles docs (S02).
Components read semantic tokens only
--ds-color-bg-brand, never--ds-core-color-blue-600. A component may read its own component tokens (--ds-comp-button-*) and no one else's. The layering is Field Guide 03's, unchanged by the shadow root.Evidence: SD04 flags
--ds-core-*and other components'--ds-comp-*.Field Guide 03, rules R11 and R13.
A component never sets a global token
Slotted elements inherit from their slot, not from their light-DOM parent. A button that sets
--ds-color-text-defaulton:hostre-themes the consumer's own markup slotted into it.Evidence: SD05 flags any
--ds-*declaration that is not the component's documented hook.CSS Shadow: elements assigned to a slot inherit from that slot (S01).
Private state lives in
--_properties on:hostVariants switch private properties (
--_bg,--_fg); the inner elements read only those. One place per variant, and no name that can collide with the page.Evidence: SD06 flags a custom property that is neither
--_*nor--<tag>-*.Lea Verou, pseudo-private custom properties (S14).
No raw colours or lengths
No hex, no colour functions, no named colours, no
pxorremin spacing, sizing, radius, border, outline or font size.transparent,currentColor,0,emand percentages stay allowed.Evidence: SD01 and SD02. The clean example and the Field Guide 15 styles pass with zero findings.
Field Guide 03, rule R15, applied inside the shadow root.
System colours only in forced-colors mode
ButtonText,Canvasand the other system colours belong inside@media (forced-colors: active). Elsewhere they bypass the theme.Evidence: SD01 allows system colours only inside the forced-colors block.
Kit rule.
No literal fallbacks in
var()var(--ds-radius-control, 6px)copies today's value into the component and hides a missing token sheet. Fall back to another token or not at all, so a broken theme fails loudly in review.Evidence: SD03 flags a fallback that is not itself a
var().Recommended practice.
Do not register tokens with
inherits: falseA registered property that does not inherit stops at every boundary, and browsers honour
@propertyonly from the document. Register tokens in the document withinherits: true, or not at all.Evidence: P06 shows
0px(the initial value) inside the shadow root; P07 shows a shadow-root@propertyignored. SD10 flags@propertyin component CSS.MDN @property; the spec says registrations are global, but csswg issue 10541 records that browsers disagree (S05, S06, S07).
Reset with
all, knowing tokens survive itall: unseton the inner control removes browser button styles and leaves every custom property in place, becausealldoes not reset custom properties.Evidence: P05: the token is still readable after
all: unset.MDN all (S04).
The page owns the theme. The component only reads it.
Section 02
Selectors stay inside the shadow root.
Selectors never cross a boundary in either direction. Rules that assume they do are dead code at best.
Suggested owners: Component owner
No global selectors in component CSS
:root,html,bodyand a bare*match nothing useful inside a shadow root. Style the host with:hostand the internals by their own selectors.Evidence: SD07. P04: a
:rootrule inside the shadow root matches nothing.Selectors are limited to a single tree (S01).
Theme by tokens on an ancestor, not
:host-context():host-context()is Chromium-only: Firefox and Safari do not support it, and MDN marks it deprecated. Switch the theme by changing token values on an ancestor such as[data-theme].Evidence: SD08. Field Guide 15 test "follows the theme without re-rendering" flips
data-themeand reads the new brand colour.MDN :host-context and browser-compat-data (S09).
Style slotted content lightly, with compound selectors
::slotted()takes one compound selector and matches only top-level slotted elements; slotted nodes belong to the page, whose rules win. Use it for spacing and icon size, not for typography the consumer controls.Evidence: SD11. P12:
::slotted(span b)matches nothing.MDN ::slotted; CSS Cascade 5 context (S12, S08).
Ship your own minimal reset
The page's reset, utility classes and component classes never reach inside. Put a small reset in
@layer resetin each component, not a copy of the page's.Evidence: P03: a page class selector does not match an inner element with the same class.
S01, S02, S03.
Fonts are declared in the document
@font-faceinside a shadow root is unreliable (a long-standing Chromium issue). Declare fonts in the page;font-familythen inherits into every component, or read it from a token.Evidence: SD10 flags
@font-facein component CSS. P16: a font family set onbodyreaches shadow text.S01, S17.
What the page cannot select, it cannot break.
Section 03
Hooks out: a small styling API.
Give consumers three levels, in this order: tokens for themes, --<tag>-* properties for documented hooks, parts for layout tweaks.
Suggested owners: Design-system lead + component owner
:hoststyles are defaults the page can overrideFor normal declarations the outer context wins, whatever the specificity.
!importantinside the shadow root reverses that and takes the override away.Evidence: P08: page beats
:host. P09::host!importantbeats the page. SD09.CSS Cascade 5, context (S08).
Expose parts by role, and document them
part="control"on the native button,@csspart controlin the JSDoc, so the manifest and Storybook list it.Evidence: The Field Guide 15 manifest lists one part,
control. P11: the page styles it with::part(control).MDN ::part (S10).
Forward nested parts with
exportparts::part()cannot be chained, so a nested component's part is unreachable unless the outer one forwards it:exportparts="control: button-control".Evidence: Every nested part a consumer needs is in the outer
exportpartsand manifest.MDN ::part, exportparts (S10, S11).
Public hooks are
--<tag>-*, default to a tokenvar(--ds-button-radius, var(--ds-radius-control)): the name says who owns it,@csspropdocuments it, the default keeps the theme in charge.Evidence: SD06 allows
--ds-button-*only inds-button; SD03 allows the token fallback.Web Awesome scopes component properties per component (S15).
A theme changes tokens, not parts
Parts are per-component escape hatches. A theme that reaches for
::part()across components has become a second design system.Evidence: Theme files contain token declarations only.
CSS Shadow on
::part()(S01); Shoelace token guidance (S16).
Three levels of override, and each one is written down.
Section 04
Cascade and delivery.
Ordering inside the component, sharing between instances, sending from the server.
Suggested owners: Engineering lead + component owner
Order component CSS with
@layer@layer reset, component, state;keeps state styles winning over variants without specificity games. The page's layer order does not apply inside.Evidence: P13: the page's
@layer b, aorder does not reach inside.CSS Cascade 5: layers are scoped to their context (S08).
Share one stylesheet across instances
A constructed stylesheet adopted by many shadow roots is parsed once and updates everywhere. Lit's
static stylesdoes this.Evidence: P14: one
replaceSyncupdates both adopting roots.MDN adoptedStyleSheets, Baseline 2023; Lit styles (S13, S02).
Budget for style duplication in server rendering
Declarative shadow DOM repeats the styles in every server-rendered instance: 2,671 of 3,087 bytes per
ds-button. Declarative sharing is only in a Chrome origin trial.Evidence:
npm run ssrin the Field Guide 15 starter.Kit run; blink-dev intent to experiment (S19).
Set
color-schemewith the themecolor-schemeinherits into shadow roots, solight-dark()and native controls inside follow the theme.Evidence: P15:
light-dark()picks the dark value when the host hascolor-scheme: dark.MDN light-dark() (S18).
Every animation has a reduced-motion answer
Spinners and transitions live inside the component, so the reduced-motion override must too.
Evidence: SD12 warns when a stylesheet animates without a
prefers-reduced-motionblock.Kit rule; WCAG 2.2 SC 2.3.3 (AAA) intent.
Parse once, share everywhere, and measure what the server sends.
Appendix A
Twelve checks, and what they caught.
node scripts/check-shadow-css.mjs <paths> reads .css files and every Lit css template in .ts and .js files. Exit code 1 on any error.
| Check | Flags | Rule |
|---|---|---|
| SD01 | Raw colours; system colours outside forced-colors | T05, T06 |
| SD02 | px or rem in spacing, sizing, radius, border, outline, font size | T05 |
| SD03 | var() with a literal fallback | T07 |
| SD04 | --ds-core-* or another component's --ds-comp-* | T02 |
| SD05 | A global --ds-* token declared inside the component | T03 |
| SD06 | A custom property that is neither --_* nor --<tag>-* | T04, T18 |
| SD07 | :root, html, body or a bare * | T10 |
| SD08 | :host-context() | T11 |
| SD09 | !important | T15 |
| SD10 | @import, @font-face or @property | T01, T08, T14 |
| SD11 | ::slotted() with a complex selector | T12 |
| SD12 | Animation or transition without reduced motion (warning) | T24 |
node scripts/check-shadow-css.mjs examples/violations
ds-button.css:6 error SD10 @import inside a shadow root
ds-button.css:8 error SD10 @font-face inside a shadow root
ds-button.css:13 error SD10 @property inside a shadow root
ds-button.css:19 error SD07 global selector ":root" matches nothing useful
ds-button.css:20 error SD01 --ds-color-bg-brand: #125bd0 uses a raw colour
ds-button.css:20 error SD05 --ds-color-bg-brand redefines a global token inside the component
ds-button.css:23 error SD07 global selector "*" matches nothing useful
ds-button.css:28 error SD09 display: inline-flex !important takes the override from the page
ds-button.css:29 error SD06 --bg is private state; prefix it --_
ds-button.css:32 error SD08 :host-context() is unsupported in Firefox and Safari
ds-button.css:33 error SD04 --ds-core-color-blue-300 is not a semantic token
ds-button.css:37 error SD02 padding: 8px 16px hard-codes a length
ds-button.css:38 error SD03 var(--ds-radius-control, 6px) hard-codes a fallback
ds-button.css:40 error SD01 color: white uses a raw colour
ds-button.css:41 error SD01 outline-color: rgb(18 91 208) uses a raw colour
ds-button.css:42 warn SD12 animation or transition with no prefers-reduced-motion block
ds-button.css:45 error SD11 ::slotted(span svg) is complex; only compound selectors match
ds-button.css:46 error SD01 fill: ButtonText uses a system colour outside forced-colors
shadow css: 1 file(s), 17 error(s), 1 warning(s)Real output, with the directory prefix removed and messages shortened to fit the page. The same command over examples/clean and the Field Guide 15 src/ds-button.styles.ts prints 2 file(s), 0 error(s), 0 warning(s).
Appendix B
The clean button, and what the page owns.
Excerpts from examples/clean/ds-button.css and examples/document.css. The component reads; the document declares.
examples/clean/ds-button.css (excerpt)
@layer reset, component, state;
@layer component {
:host {
--_bg: var(--ds-color-bg-brand);
--_fg: var(--ds-color-text-on-brand);
--_pad: var(--ds-space-inline-md);
}
:host([tone="destructive"]) {
--_bg: var(--ds-color-bg-danger);
--_fg: var(--ds-color-text-on-danger);
}
[part="control"] {
padding-inline: var(--_pad);
border-radius: var(--ds-button-radius, var(--ds-radius-control));
background: var(--_bg);
color: var(--_fg);
}
}
@media (forced-colors: active) {
[part="control"] { border-color: ButtonText; }
}examples/document.css (condensed)
@font-face { font-family: "Brand Sans"; src: url("/fonts/brand-sans.woff2") format("woff2"); }
:root { color-scheme: light; --ds-color-bg-brand: #125bd0; }
[data-theme="dark"] { color-scheme: dark; --ds-color-bg-brand: #80adff; }
ds-button.hero { --ds-button-radius: 999px; } /* documented hook */
ds-button::part(control) { letter-spacing: 0.01em; } /* local tweak */Raw values are right here: this is the generated token layer, not component CSS. The checker is for components; run the Field Guide 03 gate on the document side.
Appendix C
Sixteen probes in Chromium 153.
boundary-probe.html with no server and no dependencies. Open it in Firefox and Safari too; the only row expected to differ is P10.
| Id | Question | Observed |
|---|---|---|
| P01 | Custom property from :root inside a shadow root | rgb(18, 91, 208): crosses |
| P02 | Inherited color from the host | crosses |
| P03 | Page class selector matches an inner element | no |
| P04 | :root rule inside the shadow root matches | no |
| P05 | all: unset removes inherited custom properties | no |
| P06 | inherits: false property from the host | 0px (initial value) |
| P07 | @property declared inside a shadow root honoured | no |
| P08 | Page rule vs :host rule | page wins |
| P09 | Page rule vs :host !important | :host wins |
| P10 | :host-context() supported | yes (Chromium only) |
| P11 | ::part(label) styled by the page | yes |
| P12 | ::slotted(span b) matches | no |
| P13 | Page @layer order applies inside | no |
| P14 | One adopted sheet updates two roots | 2 of 2 |
| P15 | light-dark() follows the host's color-scheme | yes |
| P16 | font-family on body reaches shadow text | yes |
All 15 expectation rows matched in Chromium 153; P10 is informational. Firefox and Safari were not run for this guide; their P10 answer (no) comes from browser-compat-data via MDN (S09).
Keep with the component
Leave a styling review record.
One record per component release. It shows which parts of the styling API are public and what was checked.
--ds-button-* or a part is a minor release; renaming or removing one is major, exactly like a semantic token (Field Guide 03, R23).Sources / maintenance
Keep the guide current.
Sources checked 24 September 2026. Baseline dates come from web-features via MDN. Behaviour marked PROBE was measured in Chromium 153 only.