Why Custom Elements Ignore CSS and Return Wrong Event Targets (And How to Fix It)
The Problem: Web Components Misbehaving When Imported as Modules
When developing Custom Elements (Web Components), you might encounter a frustrating bug: a component works perfectly in isolation, but once integrated into a larger modular codebase, its styles stop applying and event delegation breaks or returns unexpected target nodes. You might notice behavior similar to a closed Shadow DOM, even if you didn't explicitly attach a shadow root.
The Root Cause: Global Scope Pollution with self
The primary reason for this behavior usually comes down to variable scope pollution—specifically, misusing the self keyword inside your custom element's class constructor and lifecycle callbacks.
Take a look at this problematic snippet:
class Viewer_1 extends HTMLElement {
constructor() {
self = super(); // <-- Problematic assignment
}
connectedCallback() {
let f = BuildViewer_1(this);
self.append(f); // <-- Appending to window/global scope instead of 'this'
}
}In JavaScript, self is a standard global property that points to the Window object (or Worker scope). When you write self = super() or use self.append(f), you are not creating a local reference to the custom element instance. Instead, you are interacting with window.self.
Why This Causes CSS and Event Failures
- Ignored CSS Styles: Because
self.append(f)appends the child node to the global window/document scope rather than the custom element instance (this), the resulting HTML structure becomes sibling elements rather than nested children. A CSS selector likeviewer-1 buttonwill fail because the<button>element is actually rendered outside<viewer-1>. - Broken Event Target Matching: When a user clicks the button,
evt.targetcorrectly points to the button element. However, because the button is not a child of<viewer-1>, checks likee.matches('viewer-1 button')ore.closest('viewer-1')evaluate tofalseor return unexpected parent nodes.
The Solution: Correctly Use this Reference
To fix this issue, replace all instances of self with this, and call super() without attempting to assign its return value to a global property.
Here is the corrected and clean implementation:
"use strict";
class Viewer_1 extends HTMLElement {
constructor() {
super(); // Always call super() first in custom element constructors
}
connectedCallback() {
let f = BuildViewer_1(this);
this.append(f); // Appends the fragment directly to this <viewer-1> instance
}
}
customElements.define("viewer-1", Viewer_1);
function BuildViewer_1(thisObj) {
let f = document.createDocumentFragment();
let btn = document.createElement("button");
btn.textContent = "Get Value";
btn.value = "My Value";
f.append(btn);
return f;
}Key Takeaways for Custom Element Development
- Avoid
selfin Classes: Usethisto reference the element instance. If you need to aliasthisin inner functions, use standard arrow functions orconst self = this;scoped explicitly. - Validate Element Tree Structure: Inspect the DOM tree using Browser Developer Tools. If your styles aren't applying, verify whether the child elements are actually inside the host custom element tag.
- Consider Shadow DOM for Isolation: If you intentionally want encapsulated styles and isolated DOM trees, use
this.attachShadow({ mode: 'open' })instead of appending directly to the Light DOM.