LEARNING OBJECTIVES โต
- Understand the syntactic and behavioral differences between
mode: 'open'andmode: 'closed'. - Explain how
element.shadowRootbehaves under both modes. - Implement the
WeakMapprivate encapsulation pattern to manage closed shadow roots. - Debunk the common myth that
mode: 'closed'provides security sandboxing against malicious scripts. - Evaluate real-world trade-offs in automated testing, tooling, accessibility, and design system architecture when choosing between modes.
๐ The Mental Model & Story (Intuitive Foundation)
Imagine a hotel room with an electronic door lock.
- Open Mode (
mode: 'open'): The front desk gives you a digital keycard (element.shadowRoot). Any authorized staff member, cleaning crew, or guest with a keycard can open the door, inspect the interior furniture, or adjust the thermostat. - Closed Mode (
mode: 'closed'): The door has no exterior keyhole and the front desk will always tell inquiries that the room does not exist (element.shadowRoot === null). However, whoever originally built the room kept a secret backdoor key in their pocket (a private JavaScript variable orWeakMap).
OPEN SHADOW ROOT:
+-------------------------------+
| Host: <my-card> |
| .shadowRoot โโโโโโโโโโโโโโโโบ|โโโโบ [ShadowRoot #shadow-root (open)]
+-------------------------------+ โโโ <button>Click</button>
CLOSED SHADOW ROOT:
+-------------------------------+
| Host: <my-card> |
| .shadowRoot === null |
+-------------------------------+
โ
โผ (Hidden reference kept in JS closure/WeakMap)
[Private ShadowRoot #shadow-root (closed)]
โโโ <button>Click</button>
Many engineers mistakenly assume mode: 'closed' is a security vault that stops external JavaScript from reading private data. In reality, closed mode is simply an encapsulation hintโlike marking a class member #private or prefixing a variable with an underscore _privateVar. It signals that external code should not rely on internal DOM structure, but it provides zero cryptographic or execution sandboxing.
Technical Deep Dive & Specifications
1. The attachShadow() Mode Parameter
When invoking Element.prototype.attachShadow(init), the init dictionary requires a mandatory mode property:
interface ShadowRootInit {
mode: 'open' | 'closed';
delegatesFocus?: boolean;
slotAssignment?: 'manual' | 'named';
}
// Open Mode:
const openRoot = hostElement.attachShadow({ mode: 'open' });
console.log(hostElement.shadowRoot === openRoot); // true
// Closed Mode:
const closedRoot = hostElement.attachShadow({ mode: 'closed' });
console.log(hostElement.shadowRoot); // null!
2. Behavioral Matrix: Open vs. Closed
| Feature / Behavior | mode: 'open' |
mode: 'closed' |
|---|---|---|
host.shadowRoot property |
Returns the ShadowRoot instance |
Returns null |
| CSS Style Encapsulation | Full isolation (scoped styles) | Full isolation (scoped styles) |
CSS :host & ::part() |
Fully functional | Fully functional |
| Event Retargeting | Standard event retargeting | Standard event retargeting |
| Browser DevTools | Visible and inspectable | Visible and inspectable |
| Automated Testing (Playwright / Cypress) | Native piercable locators work directly | Requires custom test harnesses or patched prototypes |
| Primary Use Case | 99% of design systems, web components, UI libraries | Special low-level browser abstractions, strictly private internal widgets |
3. Storing and Accessing Closed Shadow Roots via WeakMap
Because host.shadowRoot returns null for closed roots, the component author must hold onto the returned ShadowRoot reference in a private variable or a module-scoped WeakMap:
// Module-scoped WeakMap for private root storage
const shadowRoots = new WeakMap();
class PrivateAccordion extends HTMLElement {
constructor() {
super();
// Attach closed root and save reference in WeakMap
const root = this.attachShadow({ mode: 'closed' });
shadowRoots.set(this, root);
}
connectedCallback() {
const root = shadowRoots.get(this);
root.innerHTML = `<p>Protected internal content</p>`;
}
toggle() {
const root = shadowRoots.get(this);
// Component methods can still manipulate the closed shadow tree
root.querySelector('p').classList.toggle('open');
}
}
4. The Security Myth: Why Closed Mode is NOT a Security Sandbox
A frequent architectural anti-pattern is attempting to use mode: 'closed' to hide API tokens, credentials, or private user data from third-party scripts (e.g., analytics or ad scripts) running on the same page.
Why Closed Mode Does Not Provide Security:
- Prototype Monkey-Patching: Any script that runs before your component executes can hijack
Element.prototype.attachShadow:// Malicious or tracking script executed in <head>: const originalAttachShadow = Element.prototype.attachShadow; Element.prototype.attachShadow = function(init) { const root = originalAttachShadow.call(this, init); console.log('Intercepted shadow root:', root, 'for host:', this); // Store intercepted reference globally window.__hijackedRoots = window.__hijackedRoots || []; window.__hijackedRoots.push(root); return root; }; - Same Execution Context: Closed shadow roots execute in the exact same JavaScript thread and execution context (
window,DocumentFragment,Object.prototype) as the rest of the application. - DevTools & Browser Extensions: Browser extensions and developer tools bypass closed mode completely.
[!CAUTION] If you need true security isolation (e.g., isolating untrusted user input, payment gateways, or OAuth token handling), use Cross-Origin
<iframe>sandboxes with appropriate Content Security Policies (CSP), NOT Closed Shadow DOM.
๐ป Interactive Code Playground
Starter Code
Save this file as open-vs-closed.html and open it in your browser:
Line-by-Line Code Breakdown
- Line 57โ63: We attach an open shadow root to
#host-open. - Line 66โ70: We attach a closed shadow root to
#host-closedand store the root reference in a privateWeakMap(closedRootsMap). - Line 79:
host.shadowRootis evaluated. For#host-open, this returns[object ShadowRoot]. For#host-closed, it strictly returnsnull. - Line 80: External scripts trying to read
hostClosed.shadowRoot.innerHTMLwill throwTypeError: Cannot read properties of nullunless they have access to the closure containing the privateclosedRootvariable.
Expected Browser Render Output
+------------------------------------------------------------------------------------+
| Open Shadow Host | Closed Shadow Host |
| [Green text: I am inside an OPEN Shadow Root] | [Red text: I am inside CLOSED...] |
| [Button: Inspect Open Host] | [Button: Inspect Closed Host] |
| | |
| Output: | Output: |
| Host ID: #host-open | Host ID: #host-closed |
| host.shadowRoot: [object ShadowRoot] | host.shadowRoot: null |
| Can read innerHTML: <style>p { color: ... | Can read innerHTML: Access Denied|
| Type: OPEN (Publicly accessible) | Type: CLOSED (Hidden from host) |
+------------------------------------------------------------------------------------+๐๏ธ Hands-On Exercise
๐ฏ The Challenge: Build a Secure PIN Keypad with Safe Encapsulation
Scenario: You are building an ATM numeric keypad component <pin-pad>. The component uses mode: 'closed' so external scripts cannot query internal key DOM nodes, but exposes a clean public API (getMaskedLength() and a custom event pin-complete).
Instructions:
- Define a custom element class
PinPadthat attaches aclosedshadow root. - Use a private
WeakMapor JavaScript private class field#rootto hold theShadowRootreference. - Render a grid of 9 numeric buttons (
1through9), aClearbutton, and a PIN display area showing asterisks****. - Store the entered PIN in a private variable/field (e.g.
#enteredPin = ''). - When the user enters 4 digits, dispatch a custom event
'pin-complete'carrying{ detail: { length: 4 } }without exposing the raw PIN in the event payload. - Provide a public method
reset()on the element that clears the entered PIN.
๐ Starter Code Sandbox
โ ๏ธ Common Pitfalls
- Defaulting to
mode: 'closed'out of habit: In enterprise component libraries,closedmode causes major friction with accessibility tooling, test automation runners (like Playwright and Cypress), and custom theme inspectors. Almost all major UI libraries (e.g., Shoelace, Material Web, FAST) usemode: 'open'. - Relying on
closedmode for sensitive financial or auth tokens: Any script on the page can monkey-patchElement.prototype.attachShadowbefore your script runs, capturing every closed root instance created.
๐ก Pro Tips
- Follow the Open by Default Principle: Treat
mode: 'open'as the standard contract. Usemode: 'closed'only when writing internal low-level browser polyfills or when you have a strict architectural requirement to prevent consumers from relying on internal DOM nodes. - Use ES2022
#privateFieldswith Closed Roots: If you must use closed mode, prefer#shadowRootprivate class fields overWeakMapobjects for simpler syntax, cleaner garbage collection, and native engine optimization.
๐ Key Takeaways
mode: 'open'makes the shadow root accessible viaelement.shadowRoot.mode: 'closed'makeselement.shadowRootreturnnull, requiring the component author to retain a private reference via aWeakMapor#privateField.- Both
openandclosedmodes provide identical CSS style encapsulation and event retargeting rules. mode: 'closed'is not a security boundary and does not prevent script inspection or prototype interception.- 99% of design systems and web component libraries use
mode: 'open'for testability and accessibility compatibility. - --