Track Interactions in the Shadow DOM Using Google Tag Manager

Tracking events that take place in a shadow DOM is tricky, bceause you need to inspect the path of the event rather than just process the event target. This article guides you in creating a custom listener for tracking events within shadow DOMs (where possible).

The shadow DOM is a way to add HTML structures to the web page so that they remain isolated from the rest of the document object model (DOM). It’s a popular concept with web components, as it allows for encapsulation of web structures so that they aren’t affected by style declarations of the parent tree, for example.

However, being such a “hidden” structure, anything that happens in the shadow DOM is also hidden from Google Tag Manager’s listeners. Or, the click does get registered, but the event target ({{Click Element}}) is the node on the parent structure that hosts the shadow DOM rather than the element that was clicked within the shadow DOM.

In this example, GTM would populate the click variables so that the click appeared to land on the <div class="postShorten-wrap"> when it actually landed on the element within #shadow-root. Similarly, because the shadow DOM’s contents are hidden from the parent structure, the matches CSS selector predicate can’t be used to see what’s inside the clicked element.

We can work around this! We can’t use GTM’s built-in triggers as they don’t let us access the event object itself. But we can use a custom event listener.

For more details on how event handling within the shadow DOM works, take a look at this excellent article on the topic.

How event handling works with the shadow DOM

Event listeners within the shadow DOM work just like event listeners on regular DOM structures. An event is registered, and it populates a path through the layers of the site during the capture and bubble phases.

Some events bubble up towards the top of the DOM tree, some stay on the node where the event was registered.

The main difference with the shadow DOM is that events that start making their way up only cross the shadow DOM boundary if they have the property composed set to true.

Most events have composed set to true. Typically, the exceptions are events that are not based on UI interactions. Like these:

  • load
  • unload
  • abort
  • error

For events that have composed set to true, we can attach a custom event listener on the document node, for example, and the events that take place within the shadow DOM will propagate to our listener (assuming they also bubble, or the listener has been set to detect the capture phase instead).

However, we are still faced with the problem introduced in the beginning of this article. All events that take place in the shadow DOM are auto-delegated to the parent of the #shadow-root. This isn’t very helpful. The shadow DOM could be a huge, sprawling thing, so we need precision.

Luckily, we can use the Event.composedPath() method to get an array that represents the path the event took as it bubbled up. The very first member in the array is the item that was actually clicked (unless the shadow DOM was closed, but we’ll get back to that in a minute).

We can use this information to build our listener.

The listener

In Google Tag Manager, create a Custom HTML tag, and type or copy-paste the following code.

<script>
  (function() {
    // Set to the event you want to track
    var eventName = 'click',
    // Set to false if you don't want to use capture phase
        useCapture = true,
    // Set to false if you want to track all events and not just those in shadow DOM
        trackOnlyShadowDom = true;

    var callback = function(event) {
      if ('composed' in event && typeof event.composedPath === 'function') {
        // Get the path of elements the event climbed through, e.g.
        // [span, div, div, section, body]
        var path = event.composedPath();
        
        // Fetch reference to the element that was actually clicked
        var targetElement = path[0];
        
        // Check if the element is WITHIN the shadow DOM (ignoring the root)
        var shadowFound = path.length ? path.filter(function(i) {
          return !targetElement.shadowRoot && !!i.shadowRoot;
        }).length > 0 : false;
        
        // If only shadow DOM events should be tracked and the element is not within one, return
        if (trackOnlyShadowDom && !shadowFound) return;
        
        // Push to dataLayer
        window.dataLayer.push({
          event: 'custom_event_' + event.type,
          custom_event: {
            element: targetElement,
            elementId: targetElement.id || '',
            elementClasses: targetElement.className || '',
            elementUrl: targetElement.href || targetElement.action || '',
            elementTarget: targetElement.target || '',
            originalEvent: event,
            inShadowDom: shadowFound
          }
        });
      }
    };
    
    document.addEventListener(eventName, callback, useCapture);
  })();
</script>

You can attach a Page View trigger to this tag. After that, every single click on pages where the listener is active will be pushed into dataLayer with an object content that looks like this:

In this case, the click fell on a <div> with very few attributes, but which was contained in the shadow Dom (as isShadowDom is true).

You can then create a Custom Event trigger for custom_event_click:

And you can create Data Layer variables for the individual items in the pushed object like this:

By switching eventName to, say, submit, you can listen for form submissions instead.

If you want to avoid having the script push a message with every single event instance, you can add checks within the callback that verify the event target was a specific type of element. For example, to only push to dataLayer if the click landed on a link, you could do something like this:

var callback = function(event) {
  ...
  var targetElement = path[0];
  if (targetElement.matches('a, a *')) {
    // Run the dataLayer.push() here
  }
  ...
...

Note! Though it was just an example, you should be aware that .matches() won’t work in IE, and you’ll need to use .msMatchesSelector().

What about non-composed events?

What if you want to track events that don’t have the composed flag set to true? If you remember, those events will not propagate past the shadow DOM boundaries. Similarly, if you use the script above, they will also have the inShadowDom flag set to false, as they are practically oblivious to the fact that they are in a shadow DOM (Matrix-style).

So, you’ll have to do event handling without the power of delegation. In other words, you’ll need to add the listeners directly to the elements.

For example, if you wanted to track a load event for a <script> within the shadow DOM, the script would look like this:

<script>
  (function() {
    // Set to the event you want to track
    var eventName = 'load';
    // useCapture is irrelevant as we'll be tracking the element itself
    //  useCapture = true,
    // trackOnlyShadowDom is irrelevant as we'll be only tracking an element in the shadow DOM
    //  trackOnlyShadowDom = true;

    var callback = function(event) {
      if ('composed' in event && typeof event.composedPath === 'function') {
        // Irrelevant in this solution, as we are tracking the element itself
        // var path = event.composedPath();
        
        // Irrelevant.
        // var targetElement = path[0];
        
        var targetElement = event.target;
        
        // Irrelevant.
        // var shadowFound = path.length ? path.filter(function(i) {
        //   return !targetElement.shadowRoot && !!i.shadowRoot;
        // }).length > 0 : false;
        
        // Irrelevant
        // if (trackOnlyShadowDom && !shadowFound) return;
        
        // Push to dataLayer
        window.dataLayer.push({
          event: 'custom_event_' + event.type,
          custom_event: {
            element: targetElement,
            elementId: targetElement.id || '',
            elementClasses: targetElement.className || '',
            elementUrl: targetElement.href || targetElement.action || '',
            elementTarget: targetElement.target || '',
            originalEvent: event,
            inShadowDom: true
          }
        });
      }
    };
    
    // This is where the script sets the listener on the actual element in the shadow root
    // The script checks if the container exists in the standard DOM, then it checks if the container
    // is the shadow root, and finally it checks if the shadow DOM has the script element.
    var shadowContainer = document.querySelector('.shadowDomContainer');
    if (!!shadowContainer && !!shadowContainer.shadowRoot) {
      var scriptTarget = shadowContainer.shadowRoot.querySelector('script#someId');
      if (!!scriptTarget) scriptTarget.addEventListener(eventName, callback);
    }
  })();
</script>

Here the addEventListener call at the end is quite a bit more complex than the generic one we used before. You first need to find the node to which the shadow DOM is embedded (the shadow root). Then, accessing its shadowRoot property, you are allowed to query for elements within the shadow DOM (assuming the shadow DOM is open).

After the usual checks for whether the element exists, you can then add your listener directly to the element, and it will invoke the callback as soon as it registers the event.

What about a closed shadow DOM?

If the shadow DOM is created in closed mode, you are basically unable to do anything with the shadowRoot. If you try to access it with DOM methods, the shadowRoot property will simply return null, and similarly the composedPath() returns an array of elements that stops with the shadow root node.

Thus, on a superficial level, there’s really nothing you can do to track the precise interactions within the shadow DOM.

However, there is a workaround.

When the shadow DOM is created, if the developers store a reference to it in a global variable, you can interact with it, and you can add listeners to the elements in the document fragment if you wish.

// Create a shadow DOM
var container = document.querySelector('.someContainer');
window._shadowRoot = container.attachShadow({mode: 'closed'});

In the sample above, the global variable _shadowRoot maintains a reference to the shadow DOM, and thus you can use methods like window._shadowRoot.addEventListener(...) to manipulate and interact with elements within the shadow DOM.

It does require some cooperation with the developers, and you’d also need to justify why the mode is set to closed in the first place if the developers add an opening by way of a global variable.

Summary

Hopefully this article has been instructive. I have a gut feeling that the shadow DOM is somewhat of a mystery to many web analytics developers simply because it’s not the most common way to add elements to the web page.

However, it offers powerful tools for encapsulation and separation of concerns, and especially in lieu of clumsy iframe embeds it might make a lot of sense from a web development point of view.

The pointers in this article should give you the tools for identifying yet another potential issue with Google Tag Manager’s built-in listeners, and it should help you tackle it with the power of some custom scripting.