Published · Tags: GTM, GA4, HTML5 Video, HTML5 Audio, Brightcove, CSP, dataLayer, Event Listeners, Custom Templates, Video Engagement
HTML5
Track play, pause, progress-percent, and completion for any native HTML5 <video> or <audio> element — Brightcove, self‑hosted MP4, podcasts embedded as <audio>, or anything else that ultimately renders as one of those two tags — using a single Custom HTML listener tag and a native GA4 Event tag. One CSP hash, covers both media types, no per‑vendor JavaScript.
Note: This recipe is a 2026 refactor of a video‑tracking container I originally built in 2016 for a client running Brightcove — a container that, remarkably, is still live on their site today. The underlying pattern is not mine: it is a direct implementation of David Vallejo's excellent 2014 "Tracking HTML5 Videos with GTM" article on Thyngster, which first laid out the tag/rule/macro approach of listening to the native HTML5 media events and pushing them to the dataLayer. All credit for the original concept belongs to him. The JSON container export can be downloaded from Google Drive below.
⬇ Download the GTM Container JSON from Google Drive
Background and Rationale
Back in 2016 I built a Brightcove video‑engagement container for a client, modeled closely on David Vallejo's 2014 Thyngster write‑up: listen for the browser's native play, pause, timeupdate, and ended events on every <video> element on the page, bucket the progress into percent thresholds, and push everything to the dataLayer as a single reusable event. That container is, as of this writing, still running in production for that client a decade later — a good sign the underlying idea has aged well, even as Brightcove's own player markup and GTM's own tooling have moved on.
This post refreshes that original implementation for 2026: it swaps Universal Analytics event pushes for a native GA4 Event tag, replaces vendor‑specific URL parsing with a reusable sandboxed Custom Template, and — the one functional addition beyond Vallejo's original scope — extends the listener to cover <audio> elements as well as <video>, since podcast players and audio‑only embeds share the exact same HTMLMediaElement event API. Everything else — the tag/trigger/variable shape, the reliance on native browser events instead of polling or platform SDKs — is Vallejo's approach, unchanged in spirit.
What's Inside the Container
The exported container (gtm-html5-video-audio-engagement.json) lives in one folder, HTML5 Video Engagement, alongside a shared Analytics folder for the GA4 stream constant. It contains two tags, two triggers, a set of variables covering platform/URL parsing and event‑name mapping, and one Custom Template (URL Component Extractor) that replaces hand‑written per‑vendor JavaScript.
Tags
| Tag | Type | Fires On | Purpose |
|---|---|---|---|
HTML5 Video Listener |
Custom HTML | Window Loaded | The only custom JavaScript in the recipe. Attaches native event listeners to every <video> and <audio> element on the page and pushes a gtm.video dataLayer event on start, pause, progress‑percent, and completion. |
HTML5 Video Engagement Send Event |
GA4 Event (gaawe) |
Custom Event: gtm.video
|
Native, already‑sandboxed GA4 Event tag — not Custom HTML. Maps the built‑in Video variables plus the parsed URL variables onto GA4 event parameters using GA4 Enhanced Measurement's own naming (video_start, video_progress, video_pause, video_complete) for consistency with any Enhanced Measurement data already in the property. |
Triggers
| Trigger | Type | Conditions |
|---|---|---|
Window Loaded - All Pages |
Window Loaded | Fires the single HTML5 Video Listener tag once per page. The listener itself uses a MutationObserver to catch media elements that render after this point, so no per‑platform Element Visibility triggers are needed. |
Custom Event - gtm.video |
Custom Event |
{{_event}} equals gtm.video, filtered so {{HTML5 Video Platform Lookup}} matches ^[a-zA-Z].* (i.e., a resolvable platform name). Uses the same event name GTM's own native YouTube video tracking uses. One trigger covers start/progress/pause/complete; branch in tags with {{Video Status}} if you need per‑status tags, exactly as you would with a native YouTube trigger. |
Note — scope this trigger to your video pages: As published, Window Loaded - All Pages fires on every page. Because the listener's own DOM query (querySelectorAll('video, audio')) is cheap and a no‑op when nothing is found, this is safe — but it is wasteful to run it site‑wide. In your own build, add a page‑path or page‑hostname condition to this trigger (e.g., Page Path matches RegEx a path prefix like ^/videos/, or ^/podcasts/ for audio) so the listener only attaches on pages where you know your target <video>/<audio> elements actually appear. This is also the point at which any applicable host names belong — if your video pages live on a specific subdomain or the Brightcove player is only embedded on a defined set of URLs, restrict the trigger there rather than letting it evaluate globally.
The Listener Tag: How It Works
The HTML5 Video Listener is static vanilla JavaScript with zero {{GTM variable}} interpolation in the tag body. That's deliberate: because the script text never changes between page loads, a CSP script-src 'sha256-...' hash computed for it never changes either. All configuration lives in a plain CONFIG object at the top of the script and in optional data-* attributes on the media element itself — never in GTM variables baked into the tag body.
var CONFIG = {
eventName: 'gtm.video', // same event GTM's native YouTube tracking uses
progressThresholds: [1, 10, 25, 50, 75, 90, 99], // % markers
watchForNewVideos: true, // MutationObserver, for players injected after load
mediaSelector: 'video, audio' // tracks both <video> and <audio> elements
};
function getProvider(video) {
// 'html5video' for <video>, 'html5audio' for <audio>
return video.tagName === 'AUDIO' ? 'html5audio' : 'html5video';
}
['play', 'pause', 'ended', 'timeupdate'].forEach(function(evt) {
video.addEventListener(evt, handleEvent, false);
});
A few implementation details worth calling out:
-
Video and audio, one selector.
document.querySelectorAll('video, audio')binds the same four listeners —play,pause,ended,timeupdate— to both element types. Every downstream piece (the 8 built‑in Video variables, the URL‑parsing variables, the GA4 tag) is unchanged and now covers audio for free; onlygtm.videoProviderbranches ontagNameto reporthtml5videoorhtml5audio. -
Late‑rendered players are still caught. A
MutationObserverwatches for nodes added after the initial scan, which covers Brightcove‑style players that inject their own<video>node asynchronously, modal/lightbox players, and SPA route changes — without needing a separate Element Visibility trigger per platform. -
Title and threshold overrides via
data-*attributes, not GTM variables. An author can settitle="",data-video-title="", ordata-video-progress-thresholds="10,50,90"directly on the media tag to override the auto‑derived title or the default percent markers, on a per‑page or per‑player basis, without ever touching this script or its CSP hash.
Note — only this tag needs a CSP hash: This is the only custom JavaScript in the entire recipe. Because it contains no variable interpolation, you compute one script-src 'sha256-...' hash for it and that hash never has to be recomputed as long as the listener code itself doesn't change. Every other tag in this container — the GA4 Event tag, and the URL‑parsing Custom Template variables — runs as either a native GTM tag type or Sandboxed JavaScript, neither of which is subject to CSP script‑hash requirements, since both execute inside GTM's own sandbox, already covered by the https://www.googletagmanager.com script‑src entry your site already needs.
Reusing gtm.click Instead of Reinventing It
Where this recipe deliberately does not add anything new: play/pause button interaction beyond the native media events. Because <video controls> and <audio controls> expose native play/pause events directly on the element, there is no need to separately intercept clicks on the player's own play/pause button — the browser already tells you when playback starts and stops. If your implementation adds custom UI controls layered on top of the native player (a custom play button overlay, for example), that interaction is ordinary DOM interaction and GTM's existing gtm.click / Click trigger machinery — the same built‑in click listener and click variables ({{Click Element}}, {{Click Classes}}, {{Click ID}}, etc.) already ship with GTM — is the right tool, rather than adding bespoke click‑handling JavaScript to the listener tag.
Platform Detection: The URL Component Extractor Template
Rather than hand‑writing regex against {{Video URL}} for every vendor, one sandboxed Custom Template — URL Component Extractor — is instantiated five times, each returning a different piece of the URL (host, path, last path segment, extension, query string) using the sandboxed parseUrl API. Every instance references {{Video URL}} only inside the sandbox, never inside a Custom HTML tag, so none of it is CSP‑relevant.
| Variable | Component | Feeds Into |
|---|---|---|
HTML5 Video URL Host |
host |
HTML5 Video Platform Lookup (below) and the GA4 video_url_host parameter |
HTML5 Video URL Path |
pathname | video_url_path |
HTML5 Video URL Filename |
last path segment | video_url_filename |
HTML5 Video URL Extension |
file extension | video_url_extension |
HTML5 Video URL Query |
query string | video_url_query |
The HTML5 Video Platform Lookup variable is a Simple Lookup Table keyed off {{HTML5 Video URL Host}}, matching wildcard host patterns such as *brightcove*, *bcove*, *wistia*, *youtube*, *vimeo*, *vidyard*, and *soundcloud* to a friendly platform label. Its own note describes it as a config‑only replacement for what used to be a hand‑written "Coalesce BC ID and Self Hosted" Custom JS variable — this is the place to add a row, not edit code, when a new media platform shows up in your currentSrc values. This is also where any applicable host names belong: if your Brightcove CDN, self‑hosted media bucket, or CDN edge domain differs from the defaults shown here, add or edit rows in this lookup table rather than modifying the listener script.
GA4 Event Mapping
The HTML5 Video Engagement Send Event tag is a native GA4 Event tag (type gaawe), not Custom HTML — already sandboxed by Google. Its event name comes from HTML5 Video Event Name Lookup, a Simple Lookup keyed on {{Video Status}} that maps start → video_start, progress → video_progress, pause → video_pause, and complete → video_complete — the same names GA4 Enhanced Measurement's own native video tracking uses, so this recipe's rows sit consistently alongside any Enhanced Measurement video data already flowing into the property.
| Event Parameter | Source Variable |
|---|---|
video_provider |
Video Provider (built‑in) |
video_title |
Video Title (built‑in) |
video_url |
Video URL (built‑in) |
video_current_time |
Video Current Time (built‑in) |
video_duration |
Video Duration (built‑in) |
video_percent |
Video Percent (built‑in) |
visible |
Video Visible (built‑in) |
video_status |
Video Status (built‑in) |
video_platform |
HTML5 Video Platform Lookup |
video_url_host / _path / _filename / _extension / _query
|
the five URL Component Extractor instances |
Replace the {{Measurement Stream ID}} Constant variable (placeholder: G-AAAAAAAA) with your live GA4 Measurement ID after import; the tag references it through measurementIdOverride so it can send independently of an existing GA4 Configuration tag, or you can point it at your existing config tag's own Measurement ID variable if you'd rather not override per‑tag.
Tested With: the Brightcove In‑Page Embed
This recipe was validated against a standard Brightcove In‑Page embed — the same responsive‑wrapper pattern published on CodePen by Broadridge Media:
<!-- start brightcove In-Page embed -->
<div style="display: block; position: relative; max-width: 100%;">
<div style="padding-top: 56.25%;">
<video style="width: 100%; height: 100%; position: absolute; top: 0px; bottom: 0px; right: 0px; left: 0px;border:0;border-radius:5px" data-video-id="4093643993001" data-account="1752604059001" data-player="VyqgG8mql" data-embed="default" class="video-js"
controls></video>
<script src="//players.brightcove.net/1752604059001/VyqgG8mql_default/index.min.js"></script>
</div>
</div>
<!-- end brightcove In-Page embed -->
Brightcove's player script replaces this markup with its own rendered <video> element after the page has loaded — exactly the "player injected later" case the listener's MutationObserver is there to catch. Note the data-video-id, data-account, and data-player attributes Brightcove leaves on the element: depending on your implementation, you may want to add an additional GTM variable (a small Custom JavaScript or sandboxed template reading {{Click Element}}.getAttribute('data-video-id') equivalent) to surface Brightcove's own video ID as its own event parameter, rather than relying solely on the parsed currentSrc. Or, if your media platform exposes different metadata attributes altogether, the listener's getVideoTitle() helper is the natural place to read them instead — this is implementation‑ and platform‑dependent, so treat the shipped script as a starting point rather than a final answer for your specific player.
How to Import
- Download the JSON from the Google Drive link above.
- In GTM, go to Admin → Import Container.
- Upload
gtm-html5-video-audio-engagement.json. - Choose Merge (not Overwrite) to preserve your existing container setup, and pick a new workspace so you can review the diff before publishing.
- Update the
Measurement Stream IDConstant variable to your live GA4 Measurement ID. - Add a page‑path or hostname condition to the
Window Loaded - All Pagestrigger so the listener only attaches on pages where your<video>/<audio>elements actually live. - Review and, if needed, extend the rows in
HTML5 Video Platform Lookupto match your actual player host names (Brightcove account/player subdomain, self‑hosted CDN, etc.). - Compute a CSP
script-src 'sha256-...'hash for theHTML5 Video Listenertag's script body if your site enforces a strict CSP, and add it to your policy. No other tag in this container needs one. - Verify consent settings: the listener requires
analytics_storagein this export — adjust to match your CMP setup. - Open GTM Preview mode, load a page containing your video/audio embed, and confirm the
gtm.videoevent fires in the dataLayer on play, at each progress threshold, on pause, and on completion, and that the GA4 Event tag fires alongside it.
The full source — container JSON and documentation — is also published on GitHub. Renewed thanks to David Vallejo / Thyngster for the original 2014 pattern this recipe is built on. If you extend the platform lookup table, add support for additional data-* metadata attributes, or adapt this for a different media platform, open a pull request or issue.
Top comments (0)