How to Style Shadow DOM Components in JavaScript
One of the most compelling features of Shadow DOM is style encapsulation. Styles inside a shadow tree do not leak out, and styles from the outer page do not reach in. But this strict boundary creates a challenge: how do you allow controlled customization of a component's appearance? How do you build components that integrate with a page's design system while keeping their internals protected? Shadow DOM provides a carefully designed set of tools for this, from pseudo-classes like :host and ::slotted() to CSS custom properties that pierce the shadow boundary and the ::part() pseudo-element that exposes specific internals for styling. In this guide, you will learn every mechanism available for styling shadow DOM components, when to use each one, and how to combine them for maximum flexibility.
Styles in Shadow DOM Are Scoped
The foundation of Shadow DOM styling is scoping. When you place a <style> element inside a shadow root, its rules apply only within that shadow tree. They cannot affect elements outside, and outside styles cannot affect elements inside.
Demonstrating Scope Isolation
<style>
/* Global page styles */
h2 { color: blue; text-decoration: underline; }
.badge { background: orange; padding: 4px 8px; }
p { font-size: 20px; }
</style>
<h2>Page Heading (blue, underlined)</h2>
<p class="badge">Page badge (orange background, 20px)</p>
<my-widget></my-widget>
<script>
class MyWidget extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({ mode: 'open' });
shadow.innerHTML = `
`;
}
}
customElements.define('my-widget', MyWidget);
</script>
What renders:
- The page heading is blue and underlined.
- The page badge has an orange background at 20px.
- The widget heading is crimson with no underline.
- The widget badge has a green background at 14px.
No conflict. No specificity war. Complete isolation.
What Does Cross the Shadow Boundary
While most CSS properties are blocked by the shadow boundary, inherited properties do cross it. This is intentional and allows components to blend with the page's typography:
<style>
body {
font-family: 'Georgia', serif;
color: #333;
line-height: 1.8;
}
</style>
<inherit-demo></inherit-demo>
<script>
class InheritDemo extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({ mode: 'open' });
shadow.innerHTML = `
`;
}
}
customElements.define('inherit-demo', InheritDemo);
</script>
The text inside the shadow tree displays in Georgia, with color #333 and line-height 1.8, because these are inherited CSS properties.
Inherited properties that cross the shadow boundary include:
color,font-family,font-size,font-weight,font-styleline-height,letter-spacing,word-spacing,text-alignvisibility,cursor,direction- And all other CSS properties that inherit by default
If you want to block all inherited properties, you can reset them:
:host {
all: initial; /* Resets ALL properties, including inherited ones */
}
Using all: initial will also reset display to inline, font-family to the browser default (usually serif), and many other properties. It gives you a completely clean slate, but you will need to explicitly set everything your component needs. In most cases, selectively overriding specific inherited properties is better than a full reset.
The :host Pseudo-Classes
The :host family of pseudo-classes lets you style the host element (the custom element itself) from within the shadow tree.
:host (Basic)
The :host selector targets the element that owns the shadow root:
class StyledCard extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({ mode: 'open' });
shadow.innerHTML = `
<style>
:host {
display: block;
border: 2px solid #3498db;
border-radius: 8px;
padding: 16px;
margin: 12px 0;
font-family: system-ui, sans-serif;
background: white;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
</style>
<slot></slot>
`;
}
}
customElements.define('styled-card', StyledCard);
<styled-card>
<h3>Card Title</h3>
<p>Card content goes here.</p>
</styled-card>
The :host styles apply to the <styled-card> element itself. Without :host { display: block; }, custom elements default to display: inline, which often causes unexpected layout behavior.
External Styles Override :host
A critical rule to understand: styles set on the host element from outside always win over :host styles defined inside the shadow. This follows normal CSS specificity rules, where a direct selector on an element takes precedence over a pseudo-class selector.
<style>
/* This overrides the :host border inside the shadow */
styled-card {
border: 3px dashed red;
}
</style>
<styled-card>Content</styled-card>
The card now has a red dashed border instead of the blue solid border defined by :host. This is intentional and desirable. It means component consumers can always override the host element's styles from the outside, while :host provides sensible defaults.
:host() (Functional Form)
The :host() functional pseudo-class lets you apply styles conditionally based on whether the host element matches a specific selector:
class AlertBox extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({ mode: 'open' });
shadow.innerHTML = `
<style>
:host {
display: block;
padding: 12px 16px;
border-radius: 6px;
margin: 8px 0;
border-left: 4px solid #3498db;
background: #ebf5fb;
}
/* When the host has class="success" */
:host(.success) {
border-left-color: #27ae60;
background: #eafaf1;
}
/* When the host has class="warning" */
:host(.warning) {
border-left-color: #f39c12;
background: #fef9e7;
}
/* When the host has class="error" */
:host(.error) {
border-left-color: #e74c3c;
background: #fdedec;
}
/* When the host has a "dismissible" attribute */
:host([dismissible]) .close-btn {
display: inline-block;
}
.close-btn {
display: none;
float: right;
background: none;
border: none;
font-size: 18px;
cursor: pointer;
color: inherit;
opacity: 0.6;
}
.close-btn:hover { opacity: 1; }
</style>
<button class="close-btn">×</button>
<slot></slot>
`;
shadow.querySelector('.close-btn').addEventListener('click', () => {
this.remove();
});
}
}
customElements.define('alert-box', AlertBox);
<alert-box>Default info alert.</alert-box>
<alert-box class="success">Operation completed successfully!</alert-box>
<alert-box class="warning" dismissible>Disk space running low. Click X to dismiss.</alert-box>
<alert-box class="error">Failed to connect to server.</alert-box>
:host(.success) only applies when the host element has the success class. :host([dismissible]) only applies when the host has the dismissible attribute. This lets you style variants from inside the shadow while the consumer controls which variant to use through standard HTML attributes and classes.
:host-context() (Ancestor Matching)
The :host-context() pseudo-class matches when the host element or any of its ancestors matches the given selector. This is useful for theming, where a component adjusts its appearance based on its context in the page:
class ThemeCard extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({ mode: 'open' });
shadow.innerHTML = `
<style>
:host {
display: block;
padding: 16px;
border-radius: 8px;
margin: 8px 0;
background: #ffffff;
color: #333;
border: 1px solid #ddd;
}
/* When inside a .dark-theme ancestor */
:host-context(.dark-theme) {
background: #2c3e50;
color: #ecf0f1;
border-color: #4a6785;
}
/* When inside a .compact-layout ancestor */
:host-context(.compact-layout) {
padding: 8px;
margin: 4px 0;
font-size: 14px;
}
/* When inside a sidebar */
:host-context(aside) {
border-radius: 0;
border-left: 3px solid #3498db;
border-right: none;
border-top: none;
border-bottom: none;
}
</style>
<slot></slot>
`;
}
}
customElements.define('theme-card', ThemeCard);
<!-- Normal context -->
<theme-card>Standard card</theme-card>
<!-- Dark theme context -->
<div class="dark-theme">
<theme-card>Dark themed card</theme-card>
</div>
<!-- Compact layout context -->
<div class="compact-layout">
<theme-card>Compact card</theme-card>
</div>
<!-- Sidebar context -->
<aside>
<theme-card>Sidebar card</theme-card>
</aside>
Each <theme-card> adapts its appearance based on where it is placed in the page, without requiring the consumer to add classes or attributes to the component itself.
:host-context() has limited browser support. As of 2024, Firefox does not fully support it. Always check Can I Use and consider fallback strategies (like using :host() with attributes that the consumer sets manually) if you need broad browser compatibility.
The ::slotted() Pseudo-Element
The ::slotted() pseudo-element lets you style Light DOM elements that have been projected into a slot. This is the shadow tree's way of applying styles to slotted content.
Basic Syntax
class StyledList extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({ mode: 'open' });
shadow.innerHTML = `
<style>
::slotted(*) {
/* Applies to ALL slotted elements */
padding: 8px 12px;
margin: 4px 0;
display: block;
}
::slotted(h3) {
/* Applies only to slotted <h3> elements */
color: #2c3e50;
border-bottom: 2px solid #3498db;
padding-bottom: 8px;
}
::slotted(p) {
/* Applies only to slotted <p> elements */
color: #555;
line-height: 1.6;
}
::slotted(.highlight) {
/* Applies to slotted elements with class "highlight" */
background: #fff3cd;
border-left: 3px solid #ffc107;
}
</style>
<div class="container">
<slot></slot>
</div>
`;
}
}
customElements.define('styled-list', StyledList);
<styled-list>
<h3>Section Title</h3>
<p>Regular paragraph content.</p>
<p class="highlight">This paragraph is highlighted.</p>
<p>Another regular paragraph.</p>
</styled-list>
The ::slotted(h3) rule styles the slotted heading, ::slotted(p) styles the paragraphs, and ::slotted(.highlight) adds special styling to elements with that class.
Critical Limitation: Only Top-Level Elements
::slotted() can only target the direct slotted elements, not their descendants:
<styled-list>
<div class="item">
<span class="label">Name:</span> <!-- Cannot be targeted by ::slotted() -->
<span class="value">Alice</span> <!-- Cannot be targeted by ::slotted() -->
</div>
</styled-list>
/* This WORKS - targets the direct slotted <div> */
::slotted(div.item) {
padding: 8px;
}
/* This DOES NOT WORK - cannot reach inside slotted elements */
::slotted(div.item) .label {
font-weight: bold; /* Will NOT apply */
}
/* This also DOES NOT WORK */
::slotted(.label) {
font-weight: bold; /* Won't match - .label is not a direct child of the host */
}
This limitation exists to maintain encapsulation. The component should not have deep control over the structure of consumer-provided content. If you need to style nested content within slotted elements, use CSS custom properties (covered below) or let the consumer handle those styles.
::slotted() Specificity
Styles defined with ::slotted() have lower priority than styles directly applied to the element in the Light DOM:
<style>
/* This wins over ::slotted(p) */
my-component p {
color: red;
}
</style>
<my-component>
<p>This will be red, not blue.</p>
</my-component>
// Inside shadow DOM
shadow.innerHTML = `
<style>
::slotted(p) {
color: blue; /* Loses to the outer "my-component p" rule */
}
</style>
<slot></slot>
`;
This is consistent with the principle that the component provides sensible defaults, but the consumer can always override.
Styling Named Slots
::slotted() works with named slots as well:
shadow.innerHTML = `
<style>
/* Target elements slotted into the "header" slot */
slot[name="header"]::slotted(*) {
font-size: 24px;
font-weight: bold;
}
/* Target elements slotted into the "footer" slot */
slot[name="footer"]::slotted(*) {
font-size: 12px;
color: #999;
text-align: right;
}
</style>
<slot name="header"></slot>
<slot></slot>
<slot name="footer"></slot>
`;
You can scope ::slotted() rules to specific slots by targeting the slot element with an attribute selector first.
CSS Custom Properties for Theming
CSS custom properties (CSS variables) are the primary mechanism for piercing the shadow boundary in a controlled way. Unlike regular CSS properties, custom properties inherit through the shadow DOM, allowing consumers to influence a component's appearance without breaking encapsulation.
How It Works
The component defines custom properties with fallback values. The consumer overrides those properties from outside:
class ThemedButton extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({ mode: 'open' });
shadow.innerHTML = `
<style>
button {
/* Use custom properties with fallback values */
background: var(--btn-bg, #3498db);
color: var(--btn-color, white);
border: var(--btn-border, none);
padding: var(--btn-padding, 10px 20px);
border-radius: var(--btn-radius, 6px);
font-size: var(--btn-font-size, 16px);
cursor: pointer;
transition: opacity 0.2s;
}
button:hover {
opacity: 0.85;
}
</style>
<button><slot>Click me</slot></button>
`;
}
}
customElements.define('themed-button', ThemedButton);
<!-- Uses all defaults (blue button, white text) -->
<themed-button>Default</themed-button>
<!-- Override specific properties inline -->
<themed-button style="--btn-bg: #e74c3c; --btn-radius: 20px;">
Red Rounded
</themed-button>
<!-- Override via CSS class -->
<style>
.outline-theme {
--btn-bg: transparent;
--btn-color: #3498db;
--btn-border: 2px solid #3498db;
}
.large-theme {
--btn-padding: 16px 32px;
--btn-font-size: 20px;
--btn-radius: 10px;
}
</style>
<themed-button class="outline-theme">Outline</themed-button>
<themed-button class="outline-theme large-theme">Large Outline</themed-button>
Each button renders with a different appearance, all controlled from outside the shadow boundary using CSS custom properties. The component's internal structure remains completely hidden.
Building a Full Theme System
For larger components, you can expose a comprehensive set of custom properties:
class DataTable extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({ mode: 'open' });
shadow.innerHTML = `
<style>
:host {
display: block;
/* Document all custom properties as a "theming API" */
/* Colors */
--_header-bg: var(--table-header-bg, #2c3e50);
--_header-color: var(--table-header-color, white);
--_row-bg: var(--table-row-bg, white);
--_row-alt-bg: var(--table-row-alt-bg, #f8f9fa);
--_row-hover-bg: var(--table-row-hover-bg, #ebf5fb);
--_border-color: var(--table-border-color, #dee2e6);
/* Spacing */
--_cell-padding: var(--table-cell-padding, 10px 14px);
/* Typography */
--_font-size: var(--table-font-size, 14px);
}
table {
width: 100%;
border-collapse: collapse;
font-size: var(--_font-size);
}
th {
background: var(--_header-bg);
color: var(--_header-color);
padding: var(--_cell-padding);
text-align: left;
}
td {
padding: var(--_cell-padding);
border-bottom: 1px solid var(--_border-color);
background: var(--_row-bg);
}
tr:nth-child(even) td {
background: var(--_row-alt-bg);
}
tr:hover td {
background: var(--_row-hover-bg);
}
</style>
<table>
<thead><slot name="head"></slot></thead>
<tbody><slot></slot></tbody>
</table>
`;
}
}
customElements.define('data-table', DataTable);
<!-- Dark theme via custom properties -->
<style>
.dark-table {
--table-header-bg: #1a1a2e;
--table-header-color: #e94560;
--table-row-bg: #16213e;
--table-row-alt-bg: #1a1a2e;
--table-row-hover-bg: #0f3460;
--table-border-color: #333;
--table-font-size: 13px;
}
</style>
<data-table class="dark-table">
<tr slot="head"><th>Name</th><th>Role</th></tr>
<tr><td>Alice</td><td>Engineer</td></tr>
<tr><td>Bob</td><td>Designer</td></tr>
</data-table>
The pattern of using internal private custom properties (prefixed with --_) that reference public custom properties is a best practice. The private properties use var(--public-name, fallback), which means the consumer only needs to know the public names. The private names are implementation details that you can refactor freely.
:host {
/* Public API: --table-header-bg */
/* Private internal: --_header-bg */
--_header-bg: var(--table-header-bg, #2c3e50);
}
Custom Properties vs. Other Approaches
| Approach | Crosses Shadow? | Granularity | Consumer Control |
|---|---|---|---|
| Inherited CSS properties | Yes (by default) | Limited to inheritable props | Unintentional |
| CSS Custom Properties | Yes | Full control | Intentional, documented |
::slotted() | N/A (targets Light DOM) | Top-level only | Component-defined |
::part() | Yes | Specific parts | Consumer-defined |
| External stylesheets | No | N/A | Blocked |
::part() and the part Attribute
The ::part() pseudo-element allows the component author to expose specific internal elements for direct styling by the consumer. It is more powerful than CSS custom properties because the consumer can apply any CSS property, not just the ones the component predefined.
How It Works
The component author marks internal elements with the part attribute. The consumer then styles them using the ::part() pseudo-element:
class FancyInput extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({ mode: 'open' });
shadow.innerHTML = `
<style>
.wrapper {
display: flex;
align-items: center;
gap: 8px;
}
label {
font-weight: 600;
color: #333;
}
input {
padding: 8px 12px;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 14px;
outline: none;
flex: 1;
}
input:focus {
border-color: #3498db;
box-shadow: 0 0 0 3px rgba(52, 152, 219, 0.2);
}
.hint {
font-size: 12px;
color: #888;
margin-top: 4px;
}
</style>
<div class="wrapper" part="wrapper">
<label part="label"><slot name="label">Label</slot></label>
<input part="input" />
</div>
<div class="hint" part="hint">
<slot name="hint"></slot>
</div>
`;
}
}
customElements.define('fancy-input', FancyInput);
Now the consumer can style the exposed parts:
<style>
/* Style the input part of ALL fancy-input elements */
fancy-input::part(input) {
border: 2px solid #9b59b6;
border-radius: 20px;
padding: 10px 16px;
font-family: 'Courier New', monospace;
}
fancy-input::part(input):focus {
border-color: #8e44ad;
box-shadow: 0 0 0 3px rgba(142, 68, 173, 0.3);
}
fancy-input::part(label) {
color: #8e44ad;
text-transform: uppercase;
font-size: 12px;
letter-spacing: 1px;
}
/* Target specific instances */
fancy-input.error::part(input) {
border-color: #e74c3c;
}
fancy-input.error::part(hint) {
color: #e74c3c;
font-weight: bold;
}
</style>
<fancy-input>
<span slot="label">Username</span>
<span slot="hint">Choose a unique username.</span>
</fancy-input>
<fancy-input class="error">
<span slot="label">Email</span>
<span slot="hint">This email is already taken!</span>
</fancy-input>
The consumer has full control over how each part looks, applying any CSS properties they want. The component author decides which internal elements are exposed by adding the part attribute.
Multiple Part Names
An element can have multiple part names, separated by spaces:
<!-- Inside shadow DOM -->
<button part="button primary-action">Submit</button>
<button part="button secondary-action">Cancel</button>
/* Style all buttons */
my-form::part(button) {
padding: 8px 16px;
border-radius: 4px;
cursor: pointer;
}
/* Style only the primary button */
my-form::part(primary-action) {
background: #3498db;
color: white;
}
/* Style only the secondary button */
my-form::part(secondary-action) {
background: transparent;
color: #555;
border: 1px solid #ccc;
}
::part() Limitations
There are important restrictions to understand:
/* WORKS: Direct part styling */
my-component::part(header) {
color: red;
}
/* WORKS: Pseudo-classes on parts */
my-component::part(input):focus {
outline: 2px solid blue;
}
my-component::part(button):hover {
opacity: 0.8;
}
/* DOES NOT WORK: Combinators after ::part() */
my-component::part(header) span {
/* Cannot select descendants of a part */
}
/* DOES NOT WORK: Chaining multiple parts */
my-component::part(header)::part(icon) {
/* Cannot chain ::part() selectors */
}
You cannot use descendant selectors, child selectors, or other combinators after ::part(). Each part is styled independently. This is intentional since it prevents consumers from reaching deeper into the component than the author intended.
exportparts for Nested Components
When components are nested (a shadow DOM inside another shadow DOM), parts from inner components are not visible to the outer document. The exportparts attribute solves this:
// Inner component
class InnerWidget extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({ mode: 'open' });
shadow.innerHTML = `
<div part="inner-box">
<span part="inner-text">Inner content</span>
</div>
`;
}
}
customElements.define('inner-widget', InnerWidget);
// Outer component
class OuterWidget extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({ mode: 'open' });
shadow.innerHTML = `
<div part="outer-box">
<inner-widget exportparts="inner-text: nested-text"></inner-widget>
</div>
`;
}
}
customElements.define('outer-widget', OuterWidget);
<style>
/* Style the outer part directly */
outer-widget::part(outer-box) {
border: 2px solid blue;
padding: 16px;
}
/* Style the re-exported inner part */
outer-widget::part(nested-text) {
color: crimson;
font-weight: bold;
}
</style>
<outer-widget></outer-widget>
The exportparts="inner-text: nested-text" attribute maps the inner component's inner-text part to the outer component's nested-text name. The page can then style it via outer-widget::part(nested-text).
Constructable Stylesheets and adoptedStyleSheets
Constructable Stylesheets provide a performant way to create and share CSS across multiple shadow roots without duplicating <style> elements.
The Problem with Inline Styles
When each component instance includes its own <style> element, the browser creates a separate stylesheet object for each one:
// Each instance gets its own <style> element with identical CSS
class MyButton extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({ mode: 'open' });
shadow.innerHTML = `
<style>
/* 50 lines of CSS duplicated per instance */
button { ... }
button:hover { ... }
button:active { ... }
/* ... */
</style>
<button><slot></slot></button>
`;
}
}
If you create 100 instances of <my-button>, you get 100 copies of the same stylesheet in memory.
The Solution: Shared Stylesheets
With CSSStyleSheet constructor and adoptedStyleSheets, you create the stylesheet once and share it across all instances:
// Create the stylesheet ONCE
const buttonStyles = new CSSStyleSheet();
buttonStyles.replaceSync(`
button {
background: var(--btn-bg, #3498db);
color: var(--btn-color, white);
border: none;
padding: 10px 20px;
border-radius: 6px;
font-size: 16px;
cursor: pointer;
transition: background 0.2s;
font-family: inherit;
}
button:hover {
filter: brightness(1.1);
}
button:active {
filter: brightness(0.95);
}
button:focus-visible {
outline: 2px solid var(--btn-bg, #3498db);
outline-offset: 2px;
}
`);
class SharedButton extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({ mode: 'open' });
// Adopt the shared stylesheet - no duplication!
shadow.adoptedStyleSheets = [buttonStyles];
// No <style> element needed
shadow.innerHTML = `<button><slot>Button</slot></button>`;
}
}
customElements.define('shared-button', SharedButton);
Now, even with 100 <shared-button> instances, only one stylesheet object exists in memory. All shadow roots reference the same object.
Multiple Stylesheets
You can adopt multiple stylesheets, allowing you to compose styles:
// Base reset styles shared across ALL components
const resetStyles = new CSSStyleSheet();
resetStyles.replaceSync(`
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
`);
// Typography styles
const typographyStyles = new CSSStyleSheet();
typographyStyles.replaceSync(`
:host {
font-family: system-ui, -apple-system, sans-serif;
line-height: 1.5;
}
h1, h2, h3 { line-height: 1.2; }
`);
// Component-specific styles
const cardStyles = new CSSStyleSheet();
cardStyles.replaceSync(`
.card {
border: 1px solid #ddd;
border-radius: 8px;
padding: 16px;
}
`);
class MyCard extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({ mode: 'open' });
// Compose multiple stylesheets
shadow.adoptedStyleSheets = [resetStyles, typographyStyles, cardStyles];
shadow.innerHTML = `<div class="card"><slot></slot></div>`;
}
}
customElements.define('my-card', MyCard);
replaceSync() vs. replace()
The CSSStyleSheet API provides two methods for setting CSS content:
const sheet = new CSSStyleSheet();
// Synchronous - use for static CSS strings
sheet.replaceSync(`
.box { color: red; }
`);
// Asynchronous - use when loading CSS from external sources
sheet.replace(`
@import url('https://example.com/styles.css');
.box { color: blue; }
`).then(() => {
console.log('Stylesheet loaded and parsed');
}).catch(err => {
console.error('Failed to load stylesheet:', err);
});
replaceSync() is the most common choice for component styles since you typically have the CSS as a string literal. replace() returns a Promise and is useful when the CSS includes @import rules that need to be fetched.
Modifying Adopted Stylesheets at Runtime
Because adopted stylesheets are live objects, you can modify them and all adopting shadow roots update immediately:
const themeSheet = new CSSStyleSheet();
themeSheet.replaceSync(`
:host {
--primary: #3498db;
--bg: white;
--text: #333;
}
`);
// Later, switch to dark theme - ALL components update instantly
function setDarkTheme() {
themeSheet.replaceSync(`
:host {
--primary: #5dade2;
--bg: #1a1a2e;
--text: #ecf0f1;
}
`);
}
// Or insert/delete individual rules
function addHighlightRule() {
themeSheet.insertRule('.highlight { background: yellow; }', themeSheet.cssRules.length);
}
Adopted stylesheets open up powerful patterns for theme switching. Instead of toggling classes on every component, you modify a single shared stylesheet and every component that adopts it reflects the change immediately. This is especially efficient for large applications with hundreds of component instances.
Adopting Stylesheets on the Document
adoptedStyleSheets is not limited to shadow roots. You can also use it on the document itself:
const globalStyles = new CSSStyleSheet();
globalStyles.replaceSync(`
body { margin: 0; font-family: system-ui; }
.container { max-width: 1200px; margin: 0 auto; padding: 0 16px; }
`);
document.adoptedStyleSheets = [...document.adoptedStyleSheets, globalStyles];
This is useful for dynamically injecting global styles without creating <style> elements in the DOM.
Choosing the Right Styling Approach
Here is a decision guide for when to use each styling mechanism:
| Need | Approach |
|---|---|
| Style the host element from inside | :host, :host() |
| Adapt to page context/ancestors | :host-context() |
| Style top-level slotted content | ::slotted() |
| Provide a theming API | CSS Custom Properties |
| Allow consumers to style specific parts | part + ::part() |
| Share styles across many instances | adoptedStyleSheets |
| Reset all inherited styles | :host { all: initial; } |
These mechanisms are not mutually exclusive. A well-designed component often combines several of them:
- CSS custom properties for colors, spacing, and typography.
::part()for elements that need full consumer control.::slotted()for default formatting of projected content.:host()for variant styling based on attributes or classes.- Constructable stylesheets for performance at scale.
Summary
| Concept | Key Point |
|---|---|
| Scoped styles | Styles inside shadow DOM do not leak out; outside styles do not reach in |
| Inherited properties | color, font-family, and other inherited props cross the boundary |
:host | Styles the host element from inside the shadow; overridden by external styles |
:host(selector) | Conditional host styling based on classes, attributes, or state |
:host-context(selector) | Styles based on ancestor matching; limited browser support |
::slotted(selector) | Styles projected Light DOM elements; top-level only |
| CSS custom properties | Primary theming mechanism; inherit through shadow boundary |
part attribute | Marks internal elements as stylable from outside |
::part(name) | Consumer-side selector for exposed parts; no combinators after it |
exportparts | Re-exports parts from nested shadow DOMs |
| Constructable stylesheets | new CSSStyleSheet() for creating styles programmatically |
adoptedStyleSheets | Share a single stylesheet across many shadow roots; zero duplication |
Shadow DOM styling is a balancing act between encapsulation and customization. The platform gives you precise tools to control exactly how much styling power you expose, from the complete lockdown of scoped styles to the full flexibility of ::part(). Understanding when to use each mechanism is the key to building components that are both robust and adaptable.