Journey to Web Components: From Fundamentals to Advanced UI Architecture
Exploring the Shadow DOM
Enhancing Web Components with Encapsulated DOM Trees

Introduction#
Ever dropped a widget into a page and watched the page's CSS wreck it? The Shadow DOM is the browser's fix: a component gets a private corner of the page, and the page's styles and scripts stay out of it.
By the end, we can attach a private shadow tree to an element, choose whether outside code can look inside, and say which page styles can and cannot reach it.
You only need two things from earlier: a custom element is a class that extends HTMLElement and is registered with customElements.define('my-tag', MyClass), and the "light DOM" is just the normal page markup you write between a component's tags, the part that is not hidden in a shadow tree.
Understanding the Shadow DOM#
The Shadow DOM allows developers to attach a hidden, self-contained DOM subtree—known as a shadow tree—to a host element. This encapsulation ensures that the component's internal DOM and CSS are isolated, preventing interference from external scripts and styles. The element to which the shadow tree is attached is called the shadow host. This approach promotes reusable components with predictable behavior.
This interactive example demonstrates one of the key benefits of Shadow DOM - style encapsulation. The component contains its own styles that don't affect (or get affected by) the main document. Try clicking the "Change Global Color" button, which changes the color of paragraphs outside the shadow DOM while leaving the component's internal paragraph untouched. Notice how the emoji is only added to the text inside the Shadow DOM, not to paragraphs in the light DOM.
---css
/* Default light mode styles */
:root {
--background-color: #ffffff;
--text-color: #333333;
--link-color: #0066cc;
/* Add other light mode variables as needed */
}
/* Dark mode styles */
@media (prefers-color-scheme: dark) {
:root {
--background-color: #121212;
--text-color: #e0e0e0;
--link-color: #80b3ff;
/* Add other dark mode variables as needed */
}
}
/* Apply the styles to html and body */
html, body {
background-color: var(--background-color);
color: var(--text-color);
/* Add other common styles as needed */
}
/* Example of using the variables for other elements */
a {
color: var(--link-color);
}
---js
class BasicShadowComponent extends HTMLElement {
constructor() {
super();
// Attach a shadow root to the element
const shadow = this.attachShadow({ mode: 'open' });
// Create elements
const wrapper = document.createElement('div');
wrapper.setAttribute('class', 'component-wrapper');
const info = document.createElement('p');
info.textContent = 'This text is inside the shadow DOM!';
// Create styles
const style = document.createElement('style');
style.textContent = `
:host {
display: block;
margin: 20px 0;
font-family: Arial, sans-serif;
}
/* Dark mode detection */
@media (prefers-color-scheme: dark) {
.component-wrapper {
background-color: #2d2d2d;
color: #e0e0e0;
border: 1px solid #444;
}
}
@media (prefers-color-scheme: light) {
.component-wrapper {
background-color: #f0f0f0;
color: #333;
border: 1px solid #ddd;
}
}
.component-wrapper {
padding: 15px;
border-radius: 8px;
transition: all 0.3s ease;
}
p {
font-size: 16px;
margin: 0;
}
/* This style won't affect paragraphs outside the shadow DOM */
p::after {
content: " 🎉";
}
`;
// Attach elements to the shadow DOM
shadow.appendChild(style);
shadow.appendChild(wrapper);
wrapper.appendChild(info);
// Create a button to show the encapsulation working
const button = document.createElement('button');
button.textContent = 'Change Global Color';
button.setAttribute('class', 'demo-button');
button.addEventListener('click', () => {
// This will only affect paragraph elements outside the shadow DOM
const randomColor = '#' + Math.floor(Math.random() * 16777215).toString(16);
document.getElementById('outside-paragraph').style.color = randomColor;
});
shadow.appendChild(button);
}}
customElements.define('basic-shadow-component', BasicShadowComponent);
// Initialize for demo
document.addEventListener('DOMContentLoaded', () => {
const container = document.createElement('div');
container.innerHTML = `
<h3>Shadow DOM Style Encapsulation</h3>
<p id="outside-paragraph">This paragraph is outside the shadow DOM.</p>
<basic-shadow-component></basic-shadow-component>
<p>Another paragraph outside the shadow DOM.</p>
`;
document.body.appendChild(container);
});
Benefits of Using the Shadow DOM#
-
No collisions: a
.titleclass inside the component and a.titleclass on the page cannot affect each other. -
Drop-in reuse: the component looks the same on any page.
-
Easier debugging: a styling bug inside the component has to come from inside the component.
Poke the boundary yourself: change an attribute, fill a slot, and try to style the inside from the page.
Change the card's tone attribute and the component restyles its own border from inside.
Add a page rule that colors the card's text and it recolors the slotted heading and paragraph, which live in the page's light DOM, but not the label the component draws inside its shadow tree: a selector from outside cannot cross the boundary.
Only what the component exposes gets through, such as the CSS custom property --card-accent and the shadow part ::part(footer).
Implementing the Shadow DOM#
To utilize the Shadow DOM in a web component, follow these steps:
-
Attach a Shadow Root: Use the
attachShadowmethod on the host element to create and attach a shadow root. -
Populate the Shadow DOM: Add elements and styles to the shadow root as needed.
Example:
// Create a class for the custom element
class MyComponent extends HTMLElement {
constructor() {
super();
// Attach a shadow root to the host element
const shadow = this.attachShadow({ mode: 'open' });
// Create and style elements within the shadow DOM
const wrapper = document.createElement('div');
const style = document.createElement('style');
style.textContent = `
div {
padding: 10px;
background-color: #f0f0f0;
}
`;
wrapper.textContent = 'This is a shadow DOM component';
// Append elements to the shadow root
shadow.appendChild(style);
shadow.appendChild(wrapper);
}
}
// Define the custom element
customElements.define('my-component', MyComponent);
In this example, a custom element <my-component> is defined with an attached shadow root. The shadow DOM contains a <div> element styled with encapsulated CSS, ensuring that styles do not leak into the main document.
Shadow DOM Modes: Open vs. Closed#
When attaching a shadow root, we can specify its mode:
-
Open: The shadow root is accessible via the
shadowRootproperty of the host element. -
Closed: The shadow root is not exposed, and the
shadowRootproperty returnsnull.
Example:
// Attach an open shadow root
const openShadow = this.attachShadow({ mode: 'open' });
console.log(this.shadowRoot); // Outputs: shadowRoot object
// Attach a closed shadow root
const closedShadow = this.attachShadow({ mode: 'closed' });
console.log(this.shadowRoot); // Outputs: null
Pick open by default: it lets page scripts, tests and browser tools reach in with element.shadowRoot. Pick closed when you want the component's internals to be unreachable through that property, for example a widget that holds something page scripts should not poke at. Closed is a guard rail, not a security vault: the code that called attachShadow still holds the root, and closed mode makes testing and extending the component harder.
This example illustrates the practical difference between open and closed shadow DOM modes. The component on the top uses mode: 'open', allowing external JavaScript to access its internal elements. The bottom component uses mode: 'closed', preventing such access. Try clicking the buttons to see how JavaScript can successfully access content in the open shadow DOM but fails when attempting to access the closed shadow DOM. This demonstrates why you might choose one mode over the other depending on your security and encapsulation needs.
---css
/* Default light mode styles */
:root {
--background-color: #ffffff;
--text-color: #333333;
--link-color: #0066cc;
/* Add other light mode variables as needed */
}
/* Dark mode styles */
@media (prefers-color-scheme: dark) {
:root {
--background-color: #121212;
--text-color: #e0e0e0;
--link-color: #80b3ff;
/* Add other dark mode variables as needed */
}
}
/* Apply the styles to html and body */
html, body {
background-color: var(--background-color);
color: var(--text-color);
/* Add other common styles as needed */
}
/* Example of using the variables for other elements */
a {
color: var(--link-color);
}
---js
class OpenShadowComponent extends HTMLElement {
constructor() {
super();
// Open mode allows external JavaScript to access the shadow DOM
const shadow = this.attachShadow({ mode: 'open' });
// Apply styles and content
shadow.innerHTML = `
<style>
:host {
display: block;
margin: 10px 0;
padding: 15px;
border-radius: 8px;
font-family: Arial, sans-serif;
transition: all 0.3s ease;
}
/* Dark mode support */
@media (prefers-color-scheme: dark) {
:host {
background-color: #2d2d2d;
color: #e0e0e0;
border: 1px solid #444;
}
}
@media (prefers-color-scheme: light) {
:host {
background-color: #f0f0f0;
color: #333;
border: 1px solid #ddd;
}
}
h4 {
margin-top: 0;
}
</style>
<h4>Open Shadow DOM</h4>
<div id="open-content">This shadow root is in 'open' mode</div>
`;
}}
class ClosedShadowComponent extends HTMLElement {
constructor() {
super();
// Closed mode prevents external access to the shadow DOM
const shadow = this.attachShadow({ mode: 'closed' });
// Apply styles and content
shadow.innerHTML = `
<style>
:host {
display: block;
margin: 10px 0;
padding: 15px;
border-radius: 8px;
font-family: Arial, sans-serif;
transition: all 0.3s ease;
}
/* Dark mode support */
@media (prefers-color-scheme: dark) {
:host {
background-color: #2d2d2d;
color: #e0e0e0;
border: 1px solid #444;
}
}
@media (prefers-color-scheme: light) {
:host {
background-color: #f0f0f0;
color: #333;
border: 1px solid #ddd;
}
}
h4 {
margin-top: 0;
}
div {
position: relative;
}
div::after {
content: " 🔒";
}
</style>
<h4>Closed Shadow DOM</h4>
<div id="secret-content">This shadow root is in 'closed' mode</div>
`;
}}
customElements.define('open-shadow-component', OpenShadowComponent);
customElements.define('closed-shadow-component', ClosedShadowComponent);
// Initialize the demo
document.addEventListener('DOMContentLoaded', () => {
const container = document.createElement('div');
container.innerHTML = `
<h3>Open vs Closed Shadow DOM Demo</h3>
<div id="components-container">
<open-shadow-component id="open-component"></open-shadow-component>
<closed-shadow-component id="closed-component"></closed-shadow-component>
</div>
<div id="demo-controls">
<button id="access-open">Access Open Shadow DOM</button>
<button id="access-closed">Try to Access Closed Shadow DOM</button>
<div id="result-display" style="margin-top: 15px; padding: 10px; border-radius: 4px; min-height: 20px;">Results will appear here</div>
</div>
`;
document.body.appendChild(container);
// Apply dark mode to the result display
const updateResultStyle = () => {
const resultDisplay = document.getElementById('result-display');
if (!resultDisplay) return;
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
resultDisplay.style.backgroundColor = '#2d2d2d';
resultDisplay.style.color = '#e0e0e0';
resultDisplay.style.border = '1px solid #444';
} else {
resultDisplay.style.backgroundColor = '#f0f0f0';
resultDisplay.style.color = '#333';
resultDisplay.style.border = '1px solid #ddd';
}
};
updateResultStyle();
// Handle media query changes
if (window.matchMedia) {
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', updateResultStyle);
}
// Add event listeners to buttons
document.getElementById('access-open').addEventListener('click', () => {
const openComponent = document.getElementById('open-component');
const resultDisplay = document.getElementById('result-display');
try {
// Try to access the open shadow DOM content
const openContent = openComponent.shadowRoot.getElementById('open-content');
resultDisplay.textContent = `Successfully accessed content: "${openContent.textContent}"`;
resultDisplay.style.color = window.matchMedia('(prefers-color-scheme: dark)').matches ? '#90ee90' : '#008000';
} catch (error) {
resultDisplay.textContent = `Error: ${error.message}`;
resultDisplay.style.color = window.matchMedia('(prefers-color-scheme: dark)').matches ? '#ff6b6b' : '#ff0000';
}
});
document.getElementById('access-closed').addEventListener('click', () => {
const closedComponent = document.getElementById('closed-component');
const resultDisplay = document.getElementById('result-display');
try {
// Try to access the closed shadow DOM content
if (closedComponent.shadowRoot) {
const secretContent = closedComponent.shadowRoot.getElementById('secret-content');
resultDisplay.textContent = `Content: "${secretContent.textContent}"`;
} else {
resultDisplay.textContent = "Cannot access shadowRoot: It returns null for closed mode";
resultDisplay.style.color = window.matchMedia('(prefers-color-scheme: dark)').matches ? '#ff6b6b' : '#ff0000';
}
} catch (error) {
resultDisplay.textContent = `Error: ${error.message}`;
resultDisplay.style.color = window.matchMedia('(prefers-color-scheme: dark)').matches ? '#ff6b6b' : '#ff0000';
}
});
});
Styling Within the Shadow DOM#
Styles defined within the shadow DOM are scoped to that particular shadow tree, preventing them from affecting the global document and vice versa. This encapsulation ensures that components maintain consistent styling regardless of their environment.
Example:
const style = document.createElement('style');
style.textContent = `
p {
color: blue;
font-size: 14px;
}
`;
shadow.appendChild(style);
In this snippet, a <style> element is added to the shadow DOM, defining styles that apply only to elements within the shadow tree.
Now the failure, then the fix. A page rule p { color: red; } is global, yet it cannot reach a paragraph inside a shadow tree. One property of CSS still crosses the boundary: inherited properties such as color flow from the host element down into the shadow tree, as long as nothing inside overrides them.
Challenge. Run the block below: the page paragraph turns red and the component's paragraph stays black. Change one line (the TODO in the CSS) so the component's paragraph turns red too. Expected result: both paragraphs red.
---html
<p>I am in the page.</p>
<my-box></my-box>
---css
/* TODO: this selector matches only page paragraphs.
Change it to the component's tag name so the color is inherited inside. */
p { color: red; }
---js
class MyBox extends HTMLElement {
constructor() {
super();
const root = this.attachShadow({ mode: 'open' });
root.innerHTML = '<p>I am inside the shadow tree.</p>';
}
}
customElements.define('my-box', MyBox);
This example various techniques for styling components with Shadow DOM. It demonstrates how to style the host element (:host), how to apply context-specific styles (:host(.highlight)), and how to style content that gets slotted into your component (::slotted()). It also shows how to make your component theme-aware by using CSS variables. Try clicking the "Apply Custom Theme" buttons and the "Set Custom CSS Variables" button to see how styles can be dynamically updated while maintaining encapsulation.
---css
/* Default light mode styles */
:root {
--background-color: #ffffff;
--text-color: #333333;
--link-color: #0066cc;
/* Add other light mode variables as needed */
}
/* Dark mode styles */
@media (prefers-color-scheme: dark) {
:root {
--background-color: #121212;
--text-color: #e0e0e0;
--link-color: #80b3ff;
/* Add other dark mode variables as needed */
}
}
/* Apply the styles to html and body */
html, body {
background-color: var(--background-color);
color: var(--text-color);
/* Add other common styles as needed */
}
/* Example of using the variables for other elements */
a {
color: var(--link-color);
}
---js
class StyledComponent extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
// Render the initial component
this.render();
}
render() {
this.shadowRoot.innerHTML = `
<style>
/* Host styling */
:host {
display: block;
margin: 20px 0;
font-family: Arial, sans-serif;
}
/* Host context - changes when inside a specific container */
:host(.highlight) {
border: 2px dashed gold;
padding: 8px;
border-radius: 8px;
}
/* Component container */
.component-container {
padding: 15px;
border-radius: 8px;
transition: all 0.3s ease;
}
/* Dark mode detection */
@media (prefers-color-scheme: dark) {
.component-container {
background-color: #2d2d2d;
color: #e0e0e0;
border: 1px solid #444;
}
.custom-button {
background-color: #444;
color: #e0e0e0;
border: 1px solid #666;
}
.custom-button:hover {
background-color: #555;
}
}
@media (prefers-color-scheme: light) {
.component-container {
background-color: #f0f0f0;
color: #333;
border: 1px solid #ddd;
}
.custom-button {
background-color: #e0e0e0;
color: #333;
border: 1px solid #ccc;
}
.custom-button:hover {
background-color: #d0d0d0;
}
}
/* Styling slotted content */
::slotted(h3) {
margin-top: 0;
color: var(--heading-color, #0066cc);
}
::slotted(p) {
margin-bottom: 0;
font-style: italic;
}
.custom-button {
padding: 8px 16px;
margin-top: 10px;
border-radius: 4px;
cursor: pointer;
transition: background-color 0.2s;
}
</style>
<div class="component-container">
<slot name="heading">Default heading</slot>
<div class="content">
<slot>Default content goes here</slot>
</div>
<button class="custom-button">Apply Custom Theme</button>
</div>
`;
// Add event listener to the button
this.shadowRoot.querySelector('.custom-button').addEventListener('click', () => {
this.toggleCustomTheme();
});
}
toggleCustomTheme() {
// Toggle between themes by changing CSS variables on the host
if (this.hasAttribute('theme')) {
this.removeAttribute('theme');
this.style.removeProperty('--heading-color');
} else {
this.setAttribute('theme', 'custom');
// Generate a random color for the demo
const randomColor = '#' + Math.floor(Math.random() * 16777215).toString(16);
this.style.setProperty('--heading-color', randomColor);
}
}}
customElements.define('styled-component', StyledComponent);
// Initialize the demo
document.addEventListener('DOMContentLoaded', () => {
const container = document.createElement('div');
container.id = 'styling-demo-container';
// Apply dark mode styles to the container
const applyDarkModeStyles = () => {
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
container.style.backgroundColor = '#1a1a1a';
container.style.color = '#e0e0e0';
} else {
container.style.backgroundColor = '#ffffff';
container.style.color = '#333333';
}
};
applyDarkModeStyles();
// Listen for dark mode changes
if (window.matchMedia) {
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', applyDarkModeStyles);
}
container.innerHTML = `
<h3>Shadow DOM Styling Techniques</h3>
<p>Example 1: Basic component with slotted content</p>
<styled-component>
<h3 slot="heading">Custom Heading</h3>
<p>This paragraph is styled by the component's shadow DOM</p>
</styled-component>
<p>Example 2: Using host context classes</p>
<styled-component class="highlight">
<h3 slot="heading">Highlighted Component</h3>
<p>This component has a special highlight class</p>
</styled-component>
<div style="margin-top: 20px;">
<button id="toggle-theme-vars">Set Custom CSS Variables</button>
</div>
`;
document.body.appendChild(container);
// Style the toggle button based on dark mode
const toggleButton = document.getElementById('toggle-theme-vars');
const styleToggleButton = () => {
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
toggleButton.style.backgroundColor = '#444';
toggleButton.style.color = '#e0e0e0';
toggleButton.style.border = '1px solid #666';
toggleButton.style.padding = '8px 16px';
toggleButton.style.borderRadius = '4px';
toggleButton.style.cursor = 'pointer';
} else {
toggleButton.style.backgroundColor = '#e0e0e0';
toggleButton.style.color = '#333';
toggleButton.style.border = '1px solid #ccc';
toggleButton.style.padding = '8px 16px';
toggleButton.style.borderRadius = '4px';
toggleButton.style.cursor = 'pointer';
}
};
styleToggleButton();
if (window.matchMedia) {
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', styleToggleButton);
}
// Add event listener to toggle button
toggleButton.addEventListener('click', () => {
// Set global CSS variables that components can inherit
const components = document.querySelectorAll('styled-component');
components.forEach(component => {
if (component.hasAttribute('global-theme')) {
component.removeAttribute('global-theme');
component.style.removeProperty('--heading-color');
} else {
component.setAttribute('global-theme', '');
// Generate a random color
const randomColor = '#' + Math.floor(Math.random() * 16777215).toString(16);
component.style.setProperty('--heading-color', randomColor);
}
});
});
});
Event handling across shadow boundaries requires special consideration. This example demonstrates the difference between composed and non-composed events. Events with composed: true can cross shadow boundaries and be caught by listeners in the main document, while events with composed: false remain confined within the shadow DOM. Try clicking both buttons and observe how only the composed event appears in the document-level event log. This pattern is essential for creating components that can communicate with their parent application.
---css
/* Default light mode styles */
:root {
--background-color: #ffffff;
--text-color: #333333;
--link-color: #0066cc;
/* Add other light mode variables as needed */
}
/* Dark mode styles */
@media (prefers-color-scheme: dark) {
:root {
--background-color: #121212;
--text-color: #e0e0e0;
--link-color: #80b3ff;
/* Add other dark mode variables as needed */
}
}
/* Apply the styles to html and body */
html, body {
background-color: var(--background-color);
color: var(--text-color);
/* Add other common styles as needed */
}
/* Example of using the variables for other elements */
a {
color: var(--link-color);
}
---js
class EventComponent extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.render();
}
render() {
this.shadowRoot.innerHTML = `
<style>
:host {
display: block;
margin: 20px 0;
font-family: Arial, sans-serif;
}
.component {
padding: 15px;
border-radius: 8px;
transition: all 0.3s ease;
}
/* Dark mode detection */
@media (prefers-color-scheme: dark) {
.component {
background-color: #2d2d2d;
color: #e0e0e0;
border: 1px solid #444;
}
button {
background-color: #444;
color: #e0e0e0;
border: 1px solid #666;
}
button:hover {
background-color: #555;
}
.event-log {
background-color: #1a1a1a;
border: 1px solid #444;
}
}
@media (prefers-color-scheme: light) {
.component {
background-color: #f0f0f0;
color: #333;
border: 1px solid #ddd;
}
button {
background-color: #e0e0e0;
color: #333;
border: 1px solid #ccc;
}
button:hover {
background-color: #d0d0d0;
}
.event-log {
background-color: #f8f8f8;
border: 1px solid #ddd;
}
}
h4 {
margin-top: 0;
}
button {
padding: 8px 16px;
margin: 5px;
border-radius: 4px;
cursor: pointer;
transition: background-color 0.2s;
}
.event-log {
margin-top: 10px;
padding: 10px;
max-height: 100px;
overflow-y: auto;
font-family: monospace;
font-size: 14px;
border-radius: 4px;
}
.composed-event {
color: #4caf50;
}
.non-composed-event {
color: #f44336;
}
</style>
<div class="component">
<h4>Shadow DOM Events</h4>
<div>
<button id="composed-event">Dispatch Composed Event</button>
<button id="non-composed-event">Dispatch Non-Composed Event</button>
</div>
<div class="event-log" id="event-log">
<!-- Event logs will appear here -->
</div>
</div>
`;
// Add event listeners to buttons
this.shadowRoot.getElementById('composed-event').addEventListener('click', () => {
this.dispatchComposedEvent();
});
this.shadowRoot.getElementById('non-composed-event').addEventListener('click', () => {
this.dispatchNonComposedEvent();
});
}
// Dispatches an event that crosses shadow boundaries
dispatchComposedEvent() {
const customEvent = new CustomEvent('shadow-event', {
bubbles: true,
composed: true, // This allows the event to cross shadow boundaries
detail: { message: 'Event from shadow DOM (composed)' } });
this.logEvent('Dispatched composed event');
this.dispatchEvent(customEvent);
}
// Dispatches an event that stays within shadow boundaries
dispatchNonComposedEvent() {
const customEvent = new CustomEvent('shadow-event', {
bubbles: true,
composed: false, // This prevents the event from crossing shadow boundaries
detail: { message: 'Event from shadow DOM (non-composed)' } });
this.logEvent('Dispatched non-composed event');
this.dispatchEvent(customEvent);
}
logEvent(message, className) {
const logElement = this.shadowRoot.getElementById('event-log');
const entry = document.createElement('div');
entry.textContent = `${message}`;
if (className) {
entry.className = className;
}
logElement.appendChild(entry);
// Auto-scroll to bottom
logElement.scrollTop = logElement.scrollHeight;
}}
customElements.define('event-component', EventComponent);
// Initialize the demo
document.addEventListener('DOMContentLoaded', () => {
const container = document.createElement('div');
container.innerHTML = `
<h3>Shadow DOM Event Propagation Demo</h3>
<div id="event-demo-container">
<event-component id="event-component"></event-component>
<div id="global-event-log" style="margin-top: 20px; padding: 10px; border-radius: 4px; font-family: monospace; max-height: 100px; overflow-y: auto;">
<div><strong>Document-level event log:</strong></div>
</div>
</div>
`;
document.body.appendChild(container);
// Apply dark mode styles to the global event log
const updateGlobalLogStyle = () => {
const globalLog = document.getElementById('global-event-log');
if (!globalLog) return;
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
globalLog.style.backgroundColor = '#1a1a1a';
globalLog.style.color = '#e0e0e0';
globalLog.style.border = '1px solid #444';
} else {
globalLog.style.backgroundColor = '#f8f8f8';
globalLog.style.color = '#333';
globalLog.style.border = '1px solid #ddd';
}
};
updateGlobalLogStyle();
// Listen for dark mode changes
if (window.matchMedia) {
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', updateGlobalLogStyle);
}
// Listen for events at the document level
document.addEventListener('shadow-event', event => {
const globalLog = document.getElementById('global-event-log');
const entry = document.createElement('div');
entry.textContent = `Received: ${event.detail.message}`;
entry.style.color = '#4caf50';
globalLog.appendChild(entry);
// Auto-scroll to bottom
globalLog.scrollTop = globalLog.scrollHeight;
});
});
Practical Application: Building a Reusable Component#
Here is the whole idea in one component: an interactive card. This one is for skimming; the code is long, so run it and click around rather than reading every line. A few names in it are new. connectedCallback is a method the browser calls when the element is added to the page. observedAttributes lists the attributes the element wants to hear about, and the browser then calls attributeChangedCallback when one changes. ::slotted() is the CSS selector for light DOM children placed into a <slot>. Click on a card header to expand/collapse its content, and click on the colored circle to cycle through different themes. Notice how the default theme respects the user's dark mode preference while custom themes maintain their specific styling. This demonstrates how Shadow DOM helps create self-contained, reusable UI components that can be easily integrated into any application.
---css
/* Default light mode styles */
:root {
--background-color: #ffffff;
--text-color: #333333;
--link-color: #0066cc;
/* Add other light mode variables as needed */
}
/* Dark mode styles */
@media (prefers-color-scheme: dark) {
:root {
--background-color: #121212;
--text-color: #e0e0e0;
--link-color: #80b3ff;
/* Add other dark mode variables as needed */
}
}
/* Apply the styles to html and body */
html,
body {
background-color: var(--background-color);
color: var(--text-color);
/* Add other common styles as needed */
}
/* Example of using the variables for other elements */
a {
color: var(--link-color);
}
---js
class ShadowCard extends HTMLElement {
constructor() {
super();
// Attach open shadow root
this.attachShadow({ mode: "open" });
// Initialize properties
this._expanded = false;
this._theme = "default";
// Render the component
this.render();
}
// Attributes to observe for changes
static get observedAttributes() {
return ["title", "theme"];
}
// Handle attribute changes
attributeChangedCallback(name, oldValue, newValue) {
if (oldValue === newValue) return;
if (name === "title") {
this.updateTitle(newValue);
} else if (name === "theme") {
this._theme = newValue || "default";
this.updateTheme();
}
}
// Connected callback - component added to DOM
connectedCallback() {
// Add event listeners
const boundToggleExpand = this.toggleExpand.bind(this);
const boundCycleTheme = this.cycleTheme.bind(this);
this.shadowRoot.
querySelector(".card-header").
addEventListener("click", boundToggleExpand);
this.shadowRoot.
querySelector(".theme-toggle").
addEventListener("click", e => {
e.stopPropagation();
boundCycleTheme();
});
// Store bound methods for later removal
this._boundToggleExpand = boundToggleExpand;
this._boundCycleTheme = boundCycleTheme;
}
// Disconnected callback - component removed from DOM
disconnectedCallback() {
// Clean up event listeners
this.shadowRoot.
querySelector(".card-header").
removeEventListener("click", this._boundToggleExpand);
this.shadowRoot.
querySelector(".theme-toggle").
removeEventListener("click", this._boundCycleTheme);
}
// Toggle expand/collapse state
toggleExpand() {
this._expanded = !this._expanded;
const content = this.shadowRoot.querySelector(".card-content");
const chevron = this.shadowRoot.querySelector(".chevron");
if (this._expanded) {
content.style.maxHeight = `${content.scrollHeight}px`;
chevron.classList.add("rotated");
} else {
content.style.maxHeight = "0";
chevron.classList.remove("rotated");
}
}
// Cycle through available themes
cycleTheme() {
const themes = ["default", "primary", "success", "danger"];
const currentIndex = themes.indexOf(this._theme);
const nextIndex = (currentIndex + 1) % themes.length;
this._theme = themes[nextIndex];
this.setAttribute("theme", this._theme);
this.updateTheme();
}
// Update the card title
updateTitle(title) {
const titleElement = this.shadowRoot.querySelector(".card-title");
if (titleElement) {
titleElement.textContent = title || "Untitled Card";
}
}
// Update the card theme
updateTheme() {
const card = this.shadowRoot.querySelector(".shadow-card");
// Remove all previous theme classes
card.classList.remove(
"theme-default",
"theme-primary",
"theme-success",
"theme-danger");
// Add new theme class
card.classList.add(`theme-${this._theme}`);
}
// Render the component
render() {
this.shadowRoot.innerHTML = `
<style>
/* Base styles */
:host {
display: block;
margin: 20px 0;
font-family: Arial, sans-serif;
}
.shadow-card {
border-radius: 8px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
transition: all 0.3s ease;
}
.card-header {
padding: 15px;
display: flex;
justify-content: space-between;
align-items: center;
cursor: pointer;
user-select: none;
}
.card-title {
margin: 0;
font-size: 18px;
font-weight: bold;
}
.card-actions {
display: flex;
align-items: center;
}
.theme-toggle {
margin-right: 10px;
width: 18px;
height: 18px;
border-radius: 50%;
cursor: pointer;
border: 2px solid rgba(255, 255, 255, 0.5);
transition: transform 0.2s ease;
}
.theme-toggle:hover {
transform: scale(1.2);
}
.chevron {
width: 18px;
height: 18px;
transition: transform 0.3s ease;
display: flex;
align-items: center;
justify-content: center;
}
.chevron::after {
content: "⌵";
font-size: 18px;
display: block;
}
.chevron.rotated {
transform: rotate(180deg);
}
.card-content {
padding: 0 15px;
max-height: 0;
overflow: hidden;
transition: max-height 0.3s ease;
}
.content-inner {
padding-bottom: 15px;
}
/* Theme styles */
/* Dark mode detection for default theme */
@media (prefers-color-scheme: dark) {
.theme-default .card-header {
background-color: #2d2d2d;
color: #e0e0e0;
}
.theme-default .theme-toggle {
background-color: #555;
}
.theme-default {
border: 1px solid #444;
}
.theme-default .card-content {
background-color: #1a1a1a;
color: #e0e0e0;
}
}
@media (prefers-color-scheme: light) {
.theme-default .card-header {
background-color: #f0f0f0;
color: #333;
}
.theme-default .theme-toggle {
background-color: #ccc;
}
.theme-default {
border: 1px solid #ddd;
}
.theme-default .card-content {
background-color: #ffffff;
color: #333;
}
}
/* Custom themes (not affected by dark mode) */
.theme-primary .card-header {
background-color: #4a6da7;
color: white;
}
.theme-primary .theme-toggle {
background-color: #2c4a7c;
}
.theme-primary {
border: 1px solid #4a6da7;
}
.theme-primary .card-content {
background-color: #f0f4f8;
color: #333;
}
.theme-success .card-header {
background-color: #4caf50;
color: white;
}
.theme-success .theme-toggle {
background-color: #2e7d32;
}
.theme-success {
border: 1px solid #4caf50;
}
.theme-success .card-content {
background-color: #f1f8e9;
color: #333;
}
.theme-danger .card-header {
background-color: #f44336;
color: white;
}
.theme-danger .theme-toggle {
background-color: #c62828;
}
.theme-danger {
border: 1px solid #f44336;
}
.theme-danger .card-content {
background-color: #fef8f6;
color: #333;
}
/* Animation for hovering */
.shadow-card:hover {
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.2);
transform: translateY(-2px);
}
</style>
<div class="shadow-card theme-default">
<div class="card-header">
<h3 class="card-title">${
this.getAttribute("title") || "Untitled Card"
}</h3>
<div class="card-actions">
<div class="theme-toggle" title="Change theme"></div>
<div class="chevron"></div>
</div>
</div>
<div class="card-content">
<div class="content-inner">
<slot></slot>
</div>
</div>
</div>
`;
}}
customElements.define("shadow-card", ShadowCard);
// Initialize the demo
document.addEventListener("DOMContentLoaded", () => {
try {
console.log("Initializing Shadow Card component...");
const container = document.createElement("div");
container.id = "shadow-card-container";
// Apply some basic dark mode aware styles to the container
const applyContainerStyles = () => {
if (
window.matchMedia &&
window.matchMedia("(prefers-color-scheme: dark)").matches)
{
container.style.backgroundColor = "#1a1a1a";
container.style.color = "#e0e0e0";
} else {
container.style.backgroundColor = "#ffffff";
container.style.color = "#333333";
}
};
applyContainerStyles();
// Listen for dark mode changes
if (window.matchMedia) {
window.
matchMedia("(prefers-color-scheme: dark)").
addEventListener("change", applyContainerStyles);
}
container.innerHTML = `
<div style="max-width: 600px; margin: 0 auto; padding: 20px;">
<h3>Interactive Card Component with Shadow DOM</h3>
<p>Click on a card header to expand/collapse. Click on the colored circle to change the theme.</p>
<shadow-card title="What is Shadow DOM?">
<p>Shadow DOM provides encapsulation for DOM and CSS in a web component.</p>
<p>It allows you to include a subtree of DOM elements inside your component that is rendered separately from the main document DOM.</p>
</shadow-card>
<shadow-card title="Key Benefits" theme="primary">
<ul>
<li><strong>Style Encapsulation:</strong> CSS styles defined inside the shadow DOM don't leak out</li>
<li><strong>DOM Encapsulation:</strong> Main document JavaScript can't easily access shadow DOM elements</li>
<li><strong>Composition:</strong> Create clean, reusable components with their own internal structure</li>
</ul>
</shadow-card>
<shadow-card title="Getting Started" theme="success">
<pre style="overflow-x: auto; white-space: pre-wrap; word-break: break-word;">
const shadow = element.attachShadow({mode: 'open'});
shadow.innerHTML = \`
<style>p { color: red; }</style>
<p>Hello Shadow DOM!</p>
\`;
</pre>
<p>This creates a shadow root and adds content to it with encapsulated styles.</p>
</shadow-card>
</div>
`;
document.body.appendChild(container);
console.log("Shadow Card component initialized and added to DOM");
// Verify the components are rendered correctly
setTimeout(() => {
const cards = document.querySelectorAll("shadow-card");
console.log(`Found ${cards.length} shadow-card components on the page`);
}, 100);
} catch (error) {
console.error("Failed to initialize Shadow Card component:", error);
}
});
// Add a fallback initialization in case DOMContentLoaded already fired
if (
document.readyState === "complete" ||
document.readyState === "interactive")
{
console.log(
"DOM already loaded, initializing Shadow Card component immediately");
setTimeout(() => {
const event = new Event("DOMContentLoaded");
document.dispatchEvent(event);
}, 1);
}
Conclusion#
You can now give an element a private tree with attachShadow, decide with open or closed whether shadowRoot exposes it, and predict what the page's CSS can touch: the host, slotted light DOM children and inherited properties, but not the inner elements.
What we built by hand with document.createElement gets tedious fast. The <template> and <slot> elements let you describe a component's inside as plain HTML once, and let each use of the component fill in its own content.
Comments require JavaScript.