DEV Community

Kent Spencer
Kent Spencer

Posted on • Originally published at drewspen.blogspot.com on

Listen to Any Browser Event in GTM

Listen to Any Browser Event in GTM

A single-line swap turns this container into a universal browser event tracker — no GTM trigger type required.

Published · Tags: GTM, Google Analytics 4, JavaScript, Browser Events, Form Tracking, Custom Events

Standing on the shoulders of giants. This recipe is a modern adaptation of a technique first published by Simo Ahava in 2013, when he demonstrated how to wire native browser DOM events into GTM's dataLayer using a Custom HTML tag and a JavaScript variable. Over a decade later, the pattern holds up beautifully — this container simply updates it for GA4 and adds a richer set of element-identification variables.

Google Tag Manager ships with built-in triggers for clicks, form submissions, scroll depth, and YouTube video. But what about change, focus, blur, input, keydown, or any of the dozens of other native DOM events the browser fires? GTM has no built-in trigger for them — and that's exactly where this recipe comes in.

The onChange Listener container attaches a native document.addEventListener to any browser event you choose, then funnels every firing into GTM's dataLayer using a generic event handler JavaScript variable. One string change in the Custom HTML tag is all it takes to switch from tracking change events to any other interaction.

⬇ Download the GTM Container JSON from Google Drive

What's Inside the Container

Asset Type Name Purpose
Tag (Custom HTML) onChange Listener Attaches a native DOM event listener to document; fires on gtm.js load
Tag (GA4 Event) onChange Event Push Sends an onChange GA4 event with class, element, and path parameters
Tag (Google Tag) Google Analytics Configuration Standard GA4 config tag with environment-aware Stream ID routing
Variable (Custom JS) generic event handler Returns a callback function that pushes event metadata to the dataLayer
Variable (Custom Template) Coalesce Click Class Name If Else If lookup — returns the first non-empty class name walking up the DOM tree
Variable (Custom Template) Coalesce Click Elements If Else If lookup — returns the first non-empty label walking: Click Text → Title → Name → Value
Variable (Data Layer) Click Element Parent Class Name Reads gtm.element.parentElement.className
Variable (Data Layer) Click Element Grandparent Class Name Reads gtm.element.parentElement.parentElement.className
Variable (Data Layer) Click Element Uncle Class Name Reads gtm.element.parentElement.previousSibling.className
Variable (Data Layer) Click Element Great Grandparent Class Name Reads gtm.element.parentElement.parentElement.parentElement.className
Variable (Data Layer) Click Element Great Uncle Class Name Reads great-grandparent's previous sibling class
Variable (Data Layer) Click Element Great Grandparent thru Great Uncle Class Name Combined class walk across great-grandparent and its sibling
Variable (Auto Event) Element Name Reads the name attribute of the interacted element
Variable (Auto Event) Element Value Reads the value attribute of the interacted element
Variable (Auto Event) Element Title Reads the title attribute of the interacted element
Trigger gtm.js load Custom event on gtm.js — fires the Listener tag when GTM itself initializes
Trigger gtm.js change Custom event on gtm.change — fires the GA4 event tag on every detected change
Custom Template If Else If – Advanced Lookup Table Powers the Coalesce variables with ordered conditional fallback logic

Architecture Overview

  1. GTM initializes. The gtm.js load custom event trigger fires the onChange Listener Custom HTML tag.
  2. Native event listener is registered. The tag calls document.addEventListener('change', {{generic event handler}}, false), wiring the browser's native change event to GTM's dataLayer callback. A legacy fallback via document.attachEvent handles IE8 and below.
  3. User interacts with a form element. Any <input>, <select>, or <textarea> whose value changes fires the native browser event.
  4. The generic event handler pushes to dataLayer. The JavaScript variable callback executes and pushes a gtm.change event carrying the element reference, its class names, ID, target URL, and the original event object.
  5. The gtm.js change trigger activates. GTM detects the gtm.change dataLayer event and fires the onChange Event Push GA4 tag.
  6. GA4 receives the event. The tag sends an onChange hit with three parameters: onChange_class onChange_element onChange_path.

The Custom HTML Tag — onChange Listener

This is the entry point of the recipe. It attaches a single event listener to the document object, which means it captures events from every element on the page through event bubbling — you don't need to target individual elements.

<script>
  var eventType = 'change'; // Modify this to reflect the event type you want to listen for

  if (document.addEventListener) {
    document.addEventListener(eventType, {{generic event handler}}, false);
  }
  else if (document.attachEvent) {
    document.attachEvent('on' + eventType, {{generic event handler}});
  }
</script>
Enter fullscreen mode Exit fullscreen mode

One-line swap: The entire event type is controlled by the eventType variable at the top. Change 'change' to 'focus', 'blur', 'input', or any other valid DOM event string and the listener will track that interaction instead — no other changes needed.

The tag fires on the gtm.js load custom event trigger, meaning the listener is registered at the earliest possible moment — when GTM's own library has finished loading but before the DOM may have fully rendered. Attaching to document rather than specific elements means elements loaded later (e.g. via JavaScript frameworks) are captured automatically.

Consent gate: This tag requires analytics_storage consent before it fires. If your CMP has not granted consent, the listener is never attached and no events are sent.

The Generic Event Handler — JavaScript Variable

The second pillar of this recipe is the generic event handler Custom JavaScript Variable. Rather than hard-coding a callback inside the Custom HTML tag, the handler is abstracted into a reusable variable. This is the pattern Simo Ahava documented in 2013, and it remains the cleanest way to share an event callback across multiple listener tags.

function() {
  return function(e) {
    window.dataLayer.push({
      'event': 'gtm.' + e.type, // e.g. 'gtm.change', 'gtm.focus', 'gtm.blur'
      'gtm.element': e.target, // the DOM element that triggered the event
      'gtm.elementClasses': e.target.className || '',
      'gtm.elementId': e.target.id || '',
      'gtm.elementTarget': e.target.target || '',
      'gtm.elementUrl': e.target.href || e.target.action || '',
      'gtm.originalEvent': e // the full native Event object
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

The outer function satisfies GTM's Custom JavaScript Variable requirement (it must return a value). The inner function is the actual event callback. When the browser fires a change event, e.type is 'change', so the dataLayer event name becomes 'gtm.change' — exactly what the downstream trigger is listening for.

The same callback works for any event type. Attach this variable to a focus listener and it automatically pushes 'gtm.focus'. Attach it to blur and it pushes 'gtm.blur'. The event name is always derived from the native event, not hard-coded.

The Element-Identification Variable Stack

A key challenge with form element tracking is identifying which element fired — especially when elements are wrapped in custom UI components that shift class names and labels up or down the DOM tree. This container ships a full hierarchy of coalescing variables to solve that problem.

DOM Tree Walk Variables

Variable dataLayer Key Read Relationship to Clicked Element
Click Element Parent Class Name gtm.element.parentElement.className Direct parent
Click Element Uncle Class Name gtm.element.parentElement.previousSibling.className Parent's previous sibling
Click Element Grandparent Class Name gtm.element.parentElement.parentElement.className Two levels up
Click Element Great Uncle Class Name gtm.element.parentElement.parentElement.previousSibling.className Grandparent's sibling
Click Element Great Grandparent Class Name gtm.element.parentElement.parentElement.parentElement.className Three levels up
Click Element Great Grandparent thru Great Uncle Class Name Combined walk across great-grandparent and sibling Four-level walk

Coalesce Click Class Name

This If Else If – Advanced Lookup Table variable walks the DOM tree in order and returns the first non-empty, non-garbage class name it finds. It filters out values matching ^(undefined|null|0|true|false|NaN|)$|\[object — protecting against stringified JavaScript primitives that show up when GTM serializes DOM references. The walk order is:

  1. {{Click Classes}} (the element itself)
  2. {{Click Element Parent Class Name}}
  3. {{Click Element Uncle Class Name}}
  4. {{Click Element Grandparent Class Name}}
  5. {{Click Element Great Uncle Class Name}}
  6. {{Click Element Great Grandparent Class Name}}
  7. {{Click Element Great Grandparent thru Great Uncle Class Name}}

Coalesce Click Elements

A parallel coalescing variable for the element's human-readable label. It tries each of the following in order and returns the first non-empty value:

  1. {{Click Text}} — visible text content
  2. {{Element Title}} — the title attribute
  3. {{Element Name}} — the name attribute
  4. {{Element Value}} — the value attribute

The GA4 Event — onChange Event Push

When the gtm.js change trigger fires, the onChange Event Push GA4 tag sends an event named onChange with three parameters:

GA4 Parameter GTM Variable What It Contains
onChange_class {{Coalesce Click Class Name}} The first meaningful CSS class name found walking up the DOM tree from the changed element
onChange_element {{Coalesce Click Elements}} The first human-readable label found: text, title, name, or value
onChange_path {{Page Path}} The URL path where the interaction occurred

Triggers

Trigger Name Type Event Matched Fires
gtm.js load Custom Event gtm.js onChange Listener tag — attaches the DOM listener
gtm.js change Custom Event gtm.change onChange Event Push GA4 tag — sends the hit

Why a Custom Event trigger instead of GTM's built-in Form or Click trigger? GTM's built-in triggers only emit their own event names (gtm-form-submit, gtm-click, etc.). Because the generic event handler pushes 'gtm.' + e.type, we get a predictable namespace — gtm.change, gtm.focus, gtm.blur — that any Custom Event trigger can match with a simple equals condition.

Every Browser Event You Can Track With One String Change

Swap the eventType value in the Custom HTML tag to any of the following and the entire pipeline works without any other modification:

Form & Input Events

Event Fires When Common Use Case
change A form field's committed value changes (on blur for text, immediately for checkboxes/selects) Dropdown selections, checkbox toggles, file uploads
input A field's value changes on every keystroke or paste Real-time search box tracking, character-count milestones
focus An element receives keyboard or pointer focus Which form field a user started filling; funnel drop-off analysis
blur An element loses focus Field abandonment — user focused but did not complete
submit A form is submitted Supplement GTM's built-in form trigger for SPA forms that don't bubble correctly
reset A form is reset to defaults Detect user frustration signals
invalid A form field fails native HTML5 validation Track which required fields are left empty
select Text inside an <input> or <textarea> is selected Detect copy-to-clipboard intent on product codes or addresses

Mouse & Pointer Events

Event Fires When Common Use Case
mouseenter Pointer enters an element's bounding box (does not bubble) Hover intent on CTAs or navigation items
mouseleave Pointer leaves an element's bounding box (does not bubble) Exit intent on modal or cart panels
mouseover Pointer enters an element or any of its children (bubbles) Tooltip impressions, image hover
mouseout Pointer leaves an element or any of its children (bubbles) Navigation panel close detection
mousemove Pointer moves (fires very frequently — throttle before use) Attention heatmapping, rage-move detection
mousedown A mouse button is pressed down Long-press detection, drag start
mouseup A mouse button is released Drag end, text selection completion
contextmenu Right-click or long-press context menu is triggered Detect intent to copy/save images or links
dblclick Element is double-clicked Double-tap on mobile, edit-in-place activation
wheel Mouse wheel or trackpad scroll gesture Supplement GTM's scroll depth trigger with directional data

Keyboard Events

Event Fires When Common Use Case
keydown Any key is pressed (fires before character appears) Shortcut key usage, Escape to dismiss modal
keyup A pressed key is released Submission via Enter key in a search box
keypress A key that produces a character is pressed (deprecated but widely supported) Legacy form character tracking

Touch Events

Event Fires When Common Use Case
touchstart A touch point is placed on the screen Mobile interaction start, swipe detection initiation
touchend A touch point is removed from the screen Tap vs. swipe disambiguation
touchmove A touch point moves across the screen Scroll vs. swipe gesture classification
touchcancel A touch is interrupted (e.g. by an incoming call) Detect session interruptions on mobile

Clipboard Events

Event Fires When Common Use Case
copy Content is copied to the clipboard Track sharing of product codes, addresses, or phone numbers
cut Content is cut to the clipboard Editing behaviour in rich text areas
paste Content is pasted from the clipboard Auto-fill detection, document editor interactions

Drag & Drop Events

Event Fires When Common Use Case
dragstart User begins dragging an element File-upload widget interactions, sortable list usage
dragend Drag operation ends (drop or cancel) Completion rate of drag-and-drop flows
drop Dragged element is dropped on a valid target File drop zone tracking

Media Events (attach to a specific element, not document)

Event Fires When Common Use Case
play Media playback starts Supplement GTM's YouTube trigger for HTML5 video/audio
pause Playback is paused Engagement depth for non-YouTube video players
ended Media reaches the end Video completion rate
timeupdate Playback position changes (fires frequently — throttle) Quartile progress milestones

High-frequency events: mousemove, input, wheel, touchmove, and timeupdate can fire dozens of times per second. Sending a GA4 hit on every firing will exhaust daily collection limits and distort session data. Add a debounce or throttle wrapper inside the generic event handler before using these in production.

How to Import

  1. Download the JSON from the Google Drive link.
  2. In GTM go to Admin → Import Container.
  3. Upload on-change-browser-user-interaction-listener-recipe.json.
  4. Choose Merge to preserve existing configuration.
  5. Update Measurement Stream ID Development and Measurement Stream ID Production constants with your GA4 Measurement IDs.
  6. In GA4 go to Admin → Custom Definitions → Custom Dimensions and register onChange_class, onChange_element, and onChange_path as event-scoped dimensions.
  7. Open GTM Preview mode , interact with a form field, and confirm a gtm.change event appears in the dataLayer panel followed by an onChange GA4 tag firing.
  8. To track a different event, open the onChange Listener tag and change 'change' to your desired event type string. Update the trigger's Custom Event filter to match the new gtm.[eventType] name accordingly.
  9. Publish the container.

The full source — container JSON, all variable code, and a complete event reference — is available on GitHub.

⬇ Download GTM Container JSON

Top comments (0)