Imperative Approach
📅
What is the Imperative Approach
The imperative approach is the creation of Shadow DOM via JavaScript with full programmatic control. You manage every step of component creation yourself.
Creation via attachShadow() 🛠️
attachShadow() is a method of a DOM element that creates and attaches a Shadow Root—the root of the shadow DOM—to it. The method returns a reference to the shadow tree or null if the shadow tree could not be created.
const shadowRoot = element.attachShadow({ mode: 'open' });
The mode parameter defines the isolation level of the shadow tree. It can take two values:
'open'— the shadow DOM is accessible from external code via theelement.shadowRootproperty.'closed'— the shadow DOM is hidden and cannot be accessed from the outside.
We will talk about this later in the "Shadow DOM - Open and Closed Modes" section.
🟡
- We can create only one shadow root per element, meaning
attachShadow()can be called only once. - The
elementcan be a custom element (Custom Element) or one of the following elements: «article», «aside», «blockquote», «body», «div», «footer», «h1…h6», «header», «main», «nav», «p», «section», or «span». Others, for example,, cannot host a shadow tree.
Where to Create
1. In the Constructor
class MyComponent extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
}
}
✅ Advantages:
- Encapsulation from the start: The element is born immediately with isolated DOM and CSS; there is no moment when its internals are visible in the main document.
- Performance: The DOM is built once when the element is created.
- Predictability: You know exactly where and when the Shadow Root is created.
❌ Disadvantages
- Potentially unnecessary work if the element is never added to the DOM.
🟡 Don't forget to call super() in the constructor;
2. In connectedCallback
class MyElement extends HTMLElement {
connectedCallback() {
if (!this.shadowRoot) {
this.attachShadow({ mode: 'open' });
}
}
}
When this might be useful:
- 🚀 More performant, especially if there's no certainty that the element will actually be added to the markup. If the component is heavy and you want to delay its creation until it's actually needed (when it's inserted into the page). Although in modern browsers the difference is usually negligible.
- 🧩 Atypical code when, for example, you need to use attribute values when creating the shadow tree, since such data isn't available in the constructor yet. But usually this logic is better placed in
attributeChangedCallback.
🔴 In connectedCallback, always check if (!this.shadowRoot) to avoid accidentally creating multiple Shadow Roots on one element (this will throw an error).
🟢 Start with the constructor and move to connectedCallback if:
- You need to read attributes for Shadow DOM configuration
- Shadow DOM is not always needed
- Initialization is very heavy and needs to be delayed
- You're not sure the element will appear in the markup
3. Lazy Initialization (on demand)
class MyElement extends HTMLElement {
initializeShadow() {
if (!this.shadowRoot) {
this.attachShadow({ mode: 'open' });
}
}
}
Rarely used.
4. Outside the component class
const element = new MyElement();
element.attachShadow({ mode: 'open' }); // Might work, but anti-pattern
❌ Disadvantages
- Breaks encapsulation (external interference)
- Violates OOP principles
- Vulnerability to errors
- Problems with element reuse
- Harder to test and debug
In simple terms, for custom elements it's definitely better to do all this in one place, in the class. If you really need to create the shadow tree in some non-standard way - create a public method in the element.
Recommendation
class MyElement extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
}
connectedCallback() {
// Additional initialization based on attributes
this.updateContent();
}
}
This approach combines the advantages of the 1st and 2nd approaches.
Populating shadow DOM
The attachShadow() method returns a reference to the shadow tree which we can work with exactly like any other element. We can add and remove nodes, search for elements, and so on.
Simple component
class SimpleButton extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.shadowRoot.innerHTML = `
<style>
button {
padding: 10px 20px;
border: none;
background: blue;
color: white;
}
</style>
<button>Button</button>
`;
}
}
Dynamic component
class DynamicList extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
}
connectedCallback() {
this.render();
}
render() {
const items = JSON.parse(this.getAttribute('items') || '[]');
this.shadowRoot.innerHTML = `
<ul>
${items.map(item => `<li>${item}</li>`).join('')}
</ul>
`;
}
}
Advantages of the imperative approach
-
Dynamism and flexibility
// Generation based on data render(data){ this.shadowRoot.innerHTML = `
-
${data.map(item => `
- ${item.name} `).join('')}
-
Full control
// Dynamic element creation
const div = document.createElement('div');
div.className = 'container';
this.shadowRoot.appendChild(div);
// Gradual population
const title = document.createElement('h3');
title.textContent = 'Title';
div.appendChild(title);
- Performance optimization
- Flexible logic
// Conditional creation
if (this.hasAttribute('advanced')) {
this.createAdvancedUI();
} else {
this.createSimpleUI();
}
When to use
✅ Ideal cases:
- Dynamic UI components
- Complex business logic
- Components with changing state
⚠️ Limitations:
- Requires JavaScript
- Doesn't work with SSR
- More complex for static content