Learning Objectives
- Design a custom-property surface and treat it as a stable API
- Ship component variants with
:host() - Theme across the shadow boundary, dark mode included
- Share one stylesheet across every instance with
adoptedStyleSheets - Recognize when
::partis a versioning hazard
A theming surface is an API
Encapsulation makes the theming question unusually stark. With an ordinary <div class="card">, a consumer who wants a different border can write a selector and get one, whether you anticipated it or not. With a shadow root they cannot. There is no selector they can write, no specificity they can escalate to, no !important that helps. Whatever you did not expose, they cannot change.
The other half is the part people forget. Whatever you did expose, you have promised to keep. The moment a consumer writes --card-bg: #101828 in their stylesheet, that property name is load-bearing in somebody else's code. Renaming it, or quietly ceasing to read it, breaks their page in a way no compiler will catch — the page just renders wrong. A theming surface is not a convenience you sprinkle on at the end; it is versioned API, and it deserves the same care as a method signature.
Two rules follow from that, and both are visible in those three lines. Name properties after the component. Custom properties inherit, and they live in one flat namespace shared by the entire document. A component that reads --bg will happily pick up a --bg some unrelated widget set on <body> for its own purposes, and the two of them will fight over a name neither one owns. --card-bg cannot collide with --dialog-bg. Prefix everything.
Always supply a fallback. The second argument to var() is what renders when the page sets nothing, and a component whose defaults are all var(--card-bg) with no fallback renders as unstyled wreckage on any page that has not opted in. The fallback is what makes the component usable before anybody reads your documentation — and it doubles as documentation, since a reader can see the intended value right next to the property name.
Custom properties inherit through the boundary; ordinary rules do not
Everything above rests on one asymmetry in how the boundary works, and it is worth stating plainly because it looks like an inconsistency until you see why it is not.
Selector matching stops at the boundary. A page-level rule names elements the page can see, and the nodes inside a shadow root are not among them, so the rule simply never matches. Inheritance does not stop at the boundary. Inherited values — color, font-family, and every custom property — flow down the flattened tree, and the flattened tree runs straight through shadow roots. A custom property set on :root is therefore in scope on every node of every shadow tree on the page, and var() inside the component reads it exactly as it would read any other inherited value.
So the component author is not fighting the boundary when they design a property surface — they are using the one channel that was always open. Step 04 walks the full set of crossing points, ::part and ::slotted included; see step 04's styling strategies for the mechanics. What matters here is that of the three, this is the one that scales: it needs no coordination about node names, it works on any number of components at once, and it is the same mechanism a page already uses to theme itself.
The asymmetry is the whole mechanism
Selectors do not cross the shadow boundary. Inherited values do. That single sentence explains why my-card .title { color: red } is dead CSS while my-card { --card-title-fg: red } works, why theming is done with properties rather than rules, and why one declaration on :root can restyle a hundred components in three different shadow trees at once.
Variants with :host()
Not every styling decision belongs to the consumer. Some belong to the component: a card has a warning presentation, a button has a danger presentation, and the component author is the one who knows what those should look like. That is what :host() is for. It matches the host element from inside the shadow tree, conditioned on a selector the host matches, so the component can ship a named variant and the consumer opts in with one attribute.
Notice what the first rule sets: not background, but the property the base rule already reads. A variant that overrides the custom properties rather than the declarations stays composable — the base :host rule remains the single place where background is decided, and a consumer who sets --card-bg on the warning card from the page still wins, because for normal declarations the outer tree's rules take precedence over the shadow tree's. Set background outright in the variant and you have quietly made that card untuneable.
Where variants come from matters more than it looks. An attribute the component defines and matches with :host([variant]) is a declared part of its API, the same way step 03 treated any other attribute: enumerable, documentable, and matched identically in every browser.
⚠️ :host { display: block } breaks [hidden]
The browser's own stylesheet hides hidden elements with roughly [hidden] { display: none }. That rule lives in the UA origin, which loses to any author declaration — including the display: block you wrote on :host to stop your component behaving like an inline span. So <my-card hidden> stays visible, and the consumer files a bug about your component ignoring a global HTML attribute. The fix is the second line above: any component that sets display on its host must handle [hidden] itself. Nearly every component author ships this bug exactly once.
There is a tempting alternative that you should not take. :host-context() matches on an ancestor of the host, which sounds like the natural way to pick a theme up from a wrapper — but it is Chromium-only, with no cross-browser equivalent, and step 04's compatibility warning covers what to do instead.
Dark mode across the boundary
Dark mode is where the design pays off, and it is the argument for property-based theming in one screenshot. Because custom properties inherit, the page does not have to visit each component, or know which components exist, or care whether they use shadow DOM at all. It redeclares the properties once at the root and every consumer of them updates — including components that were written years earlier by somebody who never considered dark mode.
Both forms are in there because a real site needs both. The media query reads the operating system's preference, which is the right default and requires no interaction. But users override their system preference all the time — a dark desktop and one site they want light, a bright room, a document they are about to print — and a media query alone gives them no way to say so. The explicit toggle is what turns a system preference into a user choice.
The two have to be ordered so the toggle wins. The [data-mode] rules come last and carry an attribute selector, so they outrank the plain :root inside the media query on both count and order; the media query changes nothing about specificity by itself. Setting data-mode on <html> is then one line of JavaScript, and it is worth persisting the choice so the next page load does not undo it.
Tell the browser too
Custom properties theme what you drew; they say nothing about form controls, scrollbars, and the default canvas, which the browser draws for you. Pair the properties with color-scheme: light dark on :root — and switch it alongside the toggle — so the browser's own rendering follows your theme instead of leaving light scrollbars around a dark page.
Preferences are not suggestions
Reading prefers-color-scheme is a small piece of a much older bargain. The web is the one medium where the user holds real control over presentation: they can override your fonts, zoom your text, force their own colors, stop your animations, and tell the whole platform they prefer dark. You are not painting on a surface you own. You are handing markup to a program the user configured, on a machine that is theirs, and asking it to render on your behalf.
So honoring a stated preference is not a nicety you add if there is time left in the sprint — declining to read one is a decision to overrule somebody about their own setup. The same holds for prefers-reduced-motion, prefers-contrast, and the default font size they set years ago and have not thought about since. Ship the media query as the default, and let an explicit control override it, because the second half of that bargain is that the user gets to change their mind on your site without changing their whole system.
The demo below deliberately does not do this. It carries the explicit toggle and no media query at all, so the buttons stay the single variable while you read the theming surface — an operating system set to dark changes nothing in that frame. That is a simplification made for a teaching demo, and it is exactly the simplification not to carry into real work.
Sharing one stylesheet with adoptedStyleSheets
Everything so far has been about which values cross the boundary. This is about the rules themselves. Style scoping is per-root, so every shadow root needs the component's CSS inside it — and if you supply that CSS as a <style> element in a cloned template, each instance gets its own stylesheet object with its own rule objects. A hundred cards means a hundred of them. (Engines do cache the parsed result of identical style text, so the re-parsing is usually not where you lose; the duplicated sheets are.)
A constructed CSSStyleSheet gives you one instead. You build it once at module scope, fill it with replaceSync(), and hand it to as many shadow roots as you like. One sheet object, shared — not copied — by every root that adopts it.
"Shared" is meant literally, and it is the second reason to reach for this. Mutating the sheet later — through replaceSync() or the CSSOM — updates every shadow root that adopted it, in one operation, with no traversal of the document. The property holds a list rather than a single sheet, so a root can adopt several at once: a shared design-system sheet plus a small component-specific one is a common and sensible shape. It is an ObservableArray rather than a plain one, so assign a new array to it rather than pushing into the old.
innerHTML and an inline <style> because it keeps a first example on one screen. That is the right trade for a tutorial and the wrong one for a component you ship. Template for the markup (step 06), constructed stylesheet for the CSS — both built once at module scope, both reused by every instance.Demo: one component, three themes
Two themed-card elements, one component definition, one shared stylesheet. Every color in both cards comes from a custom property, and the three theme buttons do nothing but set data-theme on <html> — no code in the page touches a shadow root. Watch the second card as you cycle themes: its :host([variant="warning"]) rule declares --card-bg on the host itself, which outranks the value inherited from :root, so its amber holds under every theme while the first card follows the page.
Notice that the variant declares --card-fg too, and that this is not decoration. A variant that overrides some of the properties a component reads and lets the rest keep inheriting has left an incomplete surface, and the gap stays invisible until somebody applies a theme nobody tested against it. Fix a background and leave the foreground inheriting, and the first dark theme to come along hands that card near-white text to paint on its own light background — a card that renders as an empty box. Colors that depend on each other have to be overridden together. The page also carries a loud header { color: #dc2626 } rule that never matches anything, because each card's header is on the far side of the boundary.
::part is a narrower promise
Step 04 covers what ::part does and how to write it. The design question is when to reach for it, and the answer turns on what each of the two mechanisms actually names.
🎛️ A custom property names a value
--card-bg is a slot in your component's configuration. What reads it, how many rules use it, and what markup sits underneath are entirely your business. You can rewrite the shadow tree from scratch and the property keeps working, as long as something still paints with it.
🧷 A part names a node
part="header" exposes a specific element in a specific structure. Consumers write rules against it that assume it is that kind of element, in that position, with those box-model properties. Rename it, or restructure around it, and every one of those rules silently stops matching.
So the two are not interchangeable tools with different syntax; they are promises of very different width. A property commits you to reading a name. A part commits you to keeping a piece of your internal structure — which is exactly the thing encapsulation was supposed to let you change freely.
Prefer properties for values, which is most of what consumers want. Reserve ::part for hooks that are genuinely structural and that no property can express: a consumer who needs to reposition your close button, or give one internal element a layout of their own, has no property-shaped way to ask. When you do expose a part, name it for its role rather than its markup — part="dismiss" survives the day the <button> becomes an <a>; part="close-button" does not.
Both are versioned surface
Document the properties and parts you expose, treat removing or renaming either as a breaking change, and keep the list short enough that you are willing to live with it. Anything you did not document is not part of the contract — but only if you said so before somebody started relying on it.
Next Steps
In Step 8: Form-associated elements, we'll explore:
static formAssociatedand whatElementInternalshands you- Taking part in real form submission, so a custom element's value reaches the server
- Validity states and validation messages
- The form lifecycle callbacks: reset, restore, and disabled state from a fieldset