Shadow DOM Tracking
Shadow DOM is an encapsulation mechanism for web components. It intentionally isolates the internals of a component from the outer page. That isolation is exactly why GTM’s event listeners — which sit on the document — often can’t see interactions that happen inside shadow roots.
This is not a bug. It is Shadow DOM working as designed. The workarounds require understanding where the boundary is and which events can cross it.
Why GTM click triggers fail inside Shadow DOM
Section titled “Why GTM click triggers fail inside Shadow DOM”GTM listens for events using delegation on the document. When a user clicks a button inside a shadow root, the click event bubbles up through the shadow DOM, crosses the shadow boundary, and continues bubbling through the regular DOM to the document.
The problem: when the event crosses the shadow boundary, GTM’s click event listeners see it — but the target has been retargeted. Instead of pointing to the button inside the shadow root, the event.target points to the host element (the custom element tag itself, like <product-card>). GTM reads {{Click Element}}, gets <product-card>, and your click triggers that check for button classes or IDs find nothing.
<!-- What the DOM looks like --><product-card id="item-101"> <!-- #shadow-root (open) --> <div class="card"> <button class="add-to-cart">Add to Cart</button> <!-- GTM can't target this directly --> </div> <!-- /shadow-root --></product-card>A trigger checking “Click Classes contains add-to-cart” will never fire, because by the time the event reaches the document, the target is <product-card>, not the button.
Which events bubble through shadow boundaries
Section titled “Which events bubble through shadow boundaries”Events have a composed property. When composed: true, the event bubbles across shadow DOM boundaries. When composed: false, it stops at the shadow root.
Bubble through shadow DOM (composed: true):
click,dblclick,mousedown,mouseup,mouseover,mouseoutkeydown,keyup,keypressfocus,blur(as FocusEvent with composed: true)input,changetouchstart,touchend,touchmove
Do NOT bubble through shadow DOM (composed: false):
submit— This is why the built-in Form Submission trigger doesn’t work inside shadow rootsresetselect- Custom events by default (unless you set
composed: truemanually)
Approach 1: Target the host element instead
Section titled “Approach 1: Target the host element instead”If you can’t fix the component code, change your trigger to target the host element. When a click happens inside <product-card>, GTM sees Click Element as the <product-card> itself.
Change your trigger condition from:
- Click Classes — contains —
add-to-cart
To:
- Click Element — matches CSS selector —
product-card
This fires for any click anywhere inside <product-card>. It’s imprecise — you’ll fire for every click on the component, not just the button. Add additional filtering using JavaScript variables that reach into the shadow root.
Approach 2: Read shadow internals from a Custom JS variable
Section titled “Approach 2: Read shadow internals from a Custom JS variable”For open shadow roots, you can reach inside from a Custom JavaScript variable:
// Custom JS Variable: "CJS - Shadow Click Target"function() { // 'this' in GTM Custom JS variables doesn't give us the event // We need to read the element differently var event = arguments[0]; // Not available in standard GTM variables
// Alternative: look for a data attribute on the host element var hostEl = document.querySelector('product-card:hover'); if (hostEl && hostEl.shadowRoot) { var clickedEl = hostEl.shadowRoot.querySelector(':focus, :active'); if (clickedEl) return clickedEl.className; } return '';}This approach is unreliable. The :hover and :active pseudo-classes are timing-dependent and won’t always capture the right element.
Approach 3: Attach listeners inside the shadow root (Custom HTML tag)
Section titled “Approach 3: Attach listeners inside the shadow root (Custom HTML tag)”A more reliable approach for open shadow roots: attach event listeners directly inside the shadow root from a Custom HTML tag:
<!-- Custom HTML tag, fires on Window Loaded --><script>(function() { function attachShadowListeners(hostElement) { var shadow = hostElement.shadowRoot; if (!shadow) return;
shadow.addEventListener('click', function(event) { var target = event.target; var button = target.closest('.add-to-cart'); if (!button) return;
var card = hostElement.closest('[data-product-id]') || hostElement;
window.dataLayer = window.dataLayer || []; window.dataLayer.push({ event: 'add_to_cart_click', product_id: card.dataset.productId || 'unknown', product_name: card.dataset.productName || 'unknown', click_source: 'shadow_dom' }); }); }
// Attach to existing components document.querySelectorAll('product-card').forEach(attachShadowListeners);
// Attach to dynamically added components var observer = new MutationObserver(function(mutations) { mutations.forEach(function(mutation) { mutation.addedNodes.forEach(function(node) { if (node.tagName === 'PRODUCT-CARD') { attachShadowListeners(node); } }); }); });
observer.observe(document.body, { childList: true, subtree: true });})();</script>This requires open shadow roots (attachShadow({ mode: 'open' })). Closed shadow roots don’t expose shadowRoot.
Approach 4: The attachShadow monkey-patch (closed shadow roots)
Section titled “Approach 4: The attachShadow monkey-patch (closed shadow roots)”For closed shadow roots, hostElement.shadowRoot returns null. The only way to intercept is to patch attachShadow before the component library calls it:
<!-- Custom HTML tag, firing on DOM Ready — must fire BEFORE components initialize --><script>(function() { var originalAttachShadow = Element.prototype.attachShadow;
Element.prototype.attachShadow = function(options) { // Create shadow root (ignore the mode for our interception) var shadowRoot = originalAttachShadow.call(this, options); var hostElement = this;
// Attach our listener to the shadow root shadowRoot.addEventListener('click', function(event) { var button = event.target.closest('[data-gtm-action]'); if (!button) return;
window.dataLayer = window.dataLayer || []; window.dataLayer.push({ event: button.dataset.gtmEvent || 'shadow_click', click_label: button.dataset.gtmLabel || '', host_element: hostElement.tagName.toLowerCase() }); });
return shadowRoot; };})();</script>The best approach: tracking inside the component
Section titled “The best approach: tracking inside the component”The cleanest long-term solution is to push tracking events from inside the web component:
// Inside your web componentclass ProductCard extends HTMLElement { connectedCallback() { this.shadowRoot.querySelector('.add-to-cart').addEventListener('click', () => { window.dataLayer = window.dataLayer || []; window.dataLayer.push({ event: 'add_to_cart', product_id: this.dataset.productId, product_name: this.dataset.productName }); }); }}When you own the component code, this is always the right approach. It bypasses all the shadow DOM boundary issues because the dataLayer push happens inside the component, where the full event context is available.
Common mistakes
Section titled “Common mistakes”Assuming GTM click triggers see inside shadow roots
Section titled “Assuming GTM click triggers see inside shadow roots”Testing a click trigger in Preview mode and seeing the event fire doesn’t mean your trigger conditions are matching the right element. Check the Variables tab in Preview mode — {{Click Element}} will show the host element, not the shadow DOM element. Your trigger may be firing for clicks anywhere on the component.
Using the built-in Form Submission trigger for shadow DOM forms
Section titled “Using the built-in Form Submission trigger for shadow DOM forms”The submit event doesn’t cross shadow boundaries. It won’t work. Use the monkey-patch approach or push from inside the component.
Ignoring the composed event property in custom events
Section titled “Ignoring the composed event property in custom events”If your web component dispatches custom events to communicate with the parent page, set composed: true in the event options, or the event will stop at the shadow boundary:
// ❌ Stops at shadow boundarythis.dispatchEvent(new CustomEvent('product-added', { detail: { id: '123' } }));
// ✅ Crosses shadow boundarythis.dispatchEvent(new CustomEvent('product-added', { detail: { id: '123' }, bubbles: true, composed: true}));