Skip to main content

How to Track Clicks in the Shadow DOM with Google Tag Manager

William Chou | Digital Analyst

August 24, 2022


As a data analyst, I typically rely on Google Tag Manager (GTM) to configure custom event tracking to help measure certain user interactions that occur within a web page. This includes click interactions such as button clicks, outbound link clicks, navigation menu clicks, PDF download clicks, and clicks to a specific internal page. Google Tag Manager worked great … until it didn’t. 

How Analytics Usually Work

One day, I set up Google Tag Manager click metrics for a new client like I usually do, but GTM wasn’t detecting the variables as it should! This issue concerned me because variables output data I use to track custom metrics that the client cares about, such as the Click URL variable that lets me detect URLs that have “.pdf” in them. After a lot of investigation, I found that our agency had recently decided to use something called the Shadow DOM for all website builds, which impacts what GTM can detect.

What is the Shadow DOM?

Some websites use a feature called the Shadow DOM to encapsulate Web Components, which separates certain parts of the page from the main document. From a developer perspective, this is useful for isolating behavior such as CSS styling; however, by making these parts of the page less accessible, third-party products like GTM may not function as expected unless they take into account this difference.

How Shadow DOM Breaks GTM

The affected interactions are most frequently click-related, such as form submissions, clicks on encapsulated elements, or links. We’ve seen issues with GTM’s built-in variables, such as Click Classes, Click Text, Element Visibility, and Click URL, being unable to read Web Component attributes that are hidden by the Shadow DOM.

When I Googled for answers, there were fewer than five results that were of any use. The only one that truly helped was Simo Ahava’s Track Interactions in the Shadow DOM Using Google Tag Manager article. Though, even that article didn’t provide a complete solution. It was simply a starting point.

The Solution

Fortunately, our developers are rock stars. I collaborated with our developers to create a workaround solution that allows GTM to read Web Component information hidden by the Shadow DOM. Now, as a data analyst, I can continue to track what I need while our development team can continue to use encapsulated Web Components. With their help, my developer coworkers used Simo’s article  as a starting point to create a much more flexible and comprehensive solution that works for our Phase2 builds. After a couple iterations, the developers enhanced our solution, so that it could collect the data I needed in various scenarios, including tracking different types of button clicks, link clicks, and form submissions on websites powered by Drupal.

A special shout out goes to Phase2 Senior Developer, Dan Montgomery, for providing a lot of the technical expertise to write this code. 

I use custom HTML tags in GTM to insert JavaScript on the site that listens for select user interactions within a page. These event listeners then pass information via the data layer from the website to our Tag Manager container, which can then be used to populate custom variables that we’ll use in triggers to fire tags. These event listeners act as substitutes for GTM's built-in click variables, which cannot read Web Component information hidden by the Shadow DOM. 

Additionally, I use GTM’s Custom Event trigger in lieu of GTM’s built-in click triggers, including All Elements and Just Links, to fire our analytics tags.

Shadow DOM graphic

 

Why Isn’t There a Google Solution Yet?

My teammates and I are surprised that GTM has yet to release an out-of-the-box solution that can read Web Components hidden by the Shadow DOM. That’s because more and more website developers are choosing to adopt encapsulated Web Components as a best practice to keep code neat, so it is likely this will be a recurring problem for GTM users. 

Ideally, the everyday web manager or analyst should be able to enable and configure tracking without the need to substitute GTM’s built-in variables. Other popular tools, like Screaming Frog, have already recognized the issue and released patches to address it for their users.

Until then, you’ll have to use a workaround solution like I did. The good news is you won’t have to start from scratch. I won’t give away everything, but this will help guide you a lot. Here’s the code I used for link click tracking. I created something similar for other elements, such as button clicks, form submissions, and more. 

<script>
// See https://www.simoahava.com/analytics/track-interactions-in-shadow-dom-google-tag-manager/ for an overview and starting point for this code.
// See https://www.analyticsmania.com/post/google-tag-manager-click-tracking/ for a good overview of GTM's interface.
</script>

<!-- Testing script to add `dataLayer` and prevent going to new pages. -->
<script>
  // For console simulation, uncomment here to prevent links and submissions from taking us to new pages.
  /*
  if (typeof dataLayer === 'undefined') { dataLayer = []; }
  document.addEventListener('click', event => {
    event.preventDefault();
    if ('sourceEvent' in event) {
      event.sourceEvent.preventDefault();
    }
  });
  */
</script>
  
<!-- All Elements / Click replacement. -->
<script>
var customClickAttached;
customClickAttached = customClickAttached || false;

if (customClickAttached === false) {
  (function() {
    function elementToString(currentElement) {
      var string = '';

      var element = '';
      if (currentElement.tagName !== undefined) {
        element = currentElement.tagName.toLowerCase();
      }

      var id = ''
      if (currentElement.id !== undefined) {
        id = currentElement.id; 
      }

      var classes = [];
      if (currentElement.classList !== undefined) {
        classes = Array.from(currentElement.classList);
      }

      string += element;

      if (id !== '') {
        string += '#' + id;
      }

      if (classes.length > 0) {
        string += '.' + classes.join('.');
      }

      return string;
    }

    function pathToString(path) {
      return path.reduce(
        function (previousValue, currentElement) {
          var string = '';

          var currentElementString = elementToString(currentElement);

          if (currentElementString !== '') {
            string = currentElementString;
          }

          if (currentElementString === '') {
            string = previousValue;
          }

          if (currentElementString !== '' && previousValue !== '') {
            string = currentElementString + ' > ' + previousValue;
          }

          return string;
        },
        ''
      );
    }
    
    function closestInPath(tagName, path) {
      var tagNameUppercase = tagName.toUpperCase();
      
      // Spacing here seems to matter.
      return path.reduce(function(previous, current) {
        return (previous !== null && previous.tagName === tagNameUppercase) ? previous : current.tagName === tagNameUppercase ? current : null;
      }, null);
    }
    
    function customClick(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];
        
        // Capture Form information if this is a child of a form.
        var formElement = closestInPath('FORM', path);

        if (targetElement !== null) {
          // Push to dataLayer
          window.dataLayer.push({
            event: 'custom.click',
            element: targetElement,
            elementId: targetElement.id || '',
            elementClasses: targetElement.className || '',
            elementUrl: targetElement.href || targetElement.action || '',
            elementTarget: targetElement.target || '',
            clickText: targetElement.innerText || '',
            // Potential solution for capturing text nodes. targetElement.nodeName === `#text`
            // See https://www.microfocus.com/documentation/silk-test/200/en/silktestworkbench-help-en/SILKTEST-21EEFF3F-DIFFERENCEBETWEENTEXTCONTENTSINNERTEXTINNERHTML-REF.html.
            // See JS tab for WIP
            // clickText: targetElement.textContent.trim() || '',
            originalEvent: event,
            // Additional values below.
            elementValue: targetElement.value || '',
            elementType: targetElement.type || '',
            path: pathToString(path),
            formId: formElement !== null ? formElement.id : '',
            formAction: formElement !== null ? formElement.action : '',
            formClasses: formElement !== null ? formElement.className : '',
          }); 
        }
      }
    }

    document.addEventListener('click', customClick);
  })();
}
customClickAttached = true;
</script>

I hope you enjoyed reading the Phase2 blog! Please subscribe below for regular updates and industry insights.


Recommended Next
Data & Insights
Navigating the Next Wave: AI-Assisted Search in Healthcare Marketing
Purple, pink gradient
Data & Insights
HIPAA Compliant A/B Testing in Healthcare Marketing
woman talking
Data & Insights
Decoding Revised OCR Bulletin: Protecting Patient Data
Jason Hamrick Blog Graphic
Jump back to top