
March 10, 2025
Track HTML5 Video with Google Analytics 4 and Google Tag Manager
Updated: March 10th, 2025
If your website uses a no-name video player, there is a very high chance that it is a generic HTML5 video player. You must make additional configurations to track its engagement with Google Tag Manager.
In this blog post, I will show how to track HTML5 video with Google Analytics 4 and Google Tag Manager.
Table of Contents
– Hide table of contents –
- GTM recipe
- Custom JavaScript variable + Auto-event listener
- Test the listener
- Data Layer Variables, Custom Event Trigger
- GA4 event tag
- Test the setup
- Register custom dimensions
- Where can I see data in GA4 reports?
- Final words
GTM recipe
If you are in a hurry, I have prepared a GTM container template you can import and configure in your container. It will start tracking embedded HTML5 video players + send the data to GA4.
Custom JavaScript variable + Auto-event listener
The most important part of this setup is a custom code designed to keep looking for HTML5 video interactions. Originally, this solution was created by David Vallejo, but I have added some modifications to make the tracking more convenient with Google Analytics 4. But before we create this tag, we need to create a Custom JavaScript variable.
The listener we will use consists of 100+ lines of code, and if you are working with GTM, you should always try to optimize the setup so that it affects the page loading/performance as little as possible.
Instead of firing the listener on every page, we will be firing it only on those pages where the HTML5 player is actually present. That can be done with the Custom JavaScript variable.
In Google Tag Manager, go to Variables > New > Custom JavaScript and paste the following code:
function () { var video = document.getElementsByTagName('video').length; if (video > 0) { return true; } else { return false; } }
Name this variable: cjs – is HTML5 Video on a page.
Then go to Triggers > New > Window Loaded and enter the following settings:
This trigger will be activated when the page loads completely and the HTML5 video player is on a page. How will we know if the player is on a page? That Custom JavaScript variable will return true if the player is present on a page.
Now, it’s time to create a Custom HTML tag that will contain the listener’s code. Just to remind you, this tag will be looking for HTML5 video interactions, and if it spots one, we will be able to see it in the GTM preview mode, thus firing the GA4 event tag.
In GTM, go to Tags > New > Custom HTML and paste the following code
<script> // Let's wrap everything inside a function so variables are not defined as globals (function() { // This is gonna our percent buckets ( 25%-75% ) var divisor = 25; // We're going to save our players status on this object. var videos_status = {}; // This is the funcion that is gonna handle the event sent by the player listeners function eventHandler(e) { switch (e.type) { // This event type is sent everytime the player updated it's current time, // We're using for the % of the video played. case 'timeupdate': // Let's set the save the current player's video time in our status object videos_status[e.target.id].current = Math.round(e.target.currentTime); // We just want to send the percent events once var pct = Math.floor(100 * videos_status[e.target.id].current / e.target.duration); for (var j in videos_status[e.target.id]._progress_markers) { if (pct >= j && j > videos_status[e.target.id].greatest_marker) { videos_status[e.target.id].greatest_marker = j; } } // current bucket hasn't been already sent to GA?, let's push it to GTM if (videos_status[e.target.id].greatest_marker && !videos_status[e.target.id]._progress_markers[videos_status[e.target.id].greatest_marker]) { videos_status[e.target.id]._progress_markers[videos_status[e.target.id].greatest_marker] = true; dataLayer.push({ 'event': 'video', 'video_status': 'progress', 'video_provider' : 'generic html5 video player', 'video_percent': videos_status[e.target.id].greatest_marker, // We are sanitizing the current video src string, and getting just the video name part 'video_title': decodeURIComponent(e.target.currentSrc.split('/')[e.target.currentSrc.split('/').length - 1]) }); } break; // This event is fired when user's click on the play button case 'play': dataLayer.push({ 'event': 'video', 'video_status' : 'play', 'video_provider' : 'generic html5 video player', 'video_percent': videos_status[e.target.id].greatest_marker, 'video_title': decodeURIComponent(e.target.currentSrc.split('/')[e.target.currentSrc.split('/').length - 1]) }); break; // This event is fied when user's click on the pause button case 'pause': if (videos_status[e.target.id].greatest_marker < '75') { dataLayer.push({ 'event': 'video', 'video_status' : 'pause', 'video_provider' : 'generic html5 video player', 'video_percent': videos_status[e.target.id].greatest_marker, 'video_title': decodeURIComponent(e.target.currentSrc.split('/')[e.target.currentSrc.split('/').length - 1]) }); } break; // If the user ends playing the video, an Finish video will be pushed ( This equals to % played = 100 ) case 'ended': dataLayer.push({ 'event': 'video', 'video_status' : 'complete', 'video_provider' : 'generic html5 video player', 'video_percent': '100', 'video_title': decodeURIComponent(e.target.currentSrc.split('/')[e.target.currentSrc.split('/').length - 1]) }); break; default: break; } } // We need to configure the listeners // Let's grab all the the "video" objects on the current page var videos = document.getElementsByTagName('video'); for (var i = 0; i < videos.length; i++) { // In order to have some id to reference in our status object, we are adding an id to the video objects // that don't have an id attribute. var videoTagId; if (!videos[i].getAttribute('id')) { // Generate a random alphanumeric string to use is as the id videoTagId = 'html5_video_' + Math.random().toString(36).slice(2); videos[i].setAttribute('id', videoTagId); }// Current video has alredy a id attribute, then let's use it <img draggable="false" class="emoji" alt="?" src="https://s.w.org/images/core/emoji/2/svg/1f642.svg"> else { videoTagId = videos[i].getAttribute('id'); } // Video Status Object declaration videos_status[videoTagId] = {}; // We'll save the highest percent mark played by the user in the current video. videos_status[videoTagId].greatest_marker = 0; // Let's set the progress markers, so we can know afterwards which ones have been already sent. videos_status[videoTagId]._progress_markers = {}; for (j = 0; j < 100; j++) { videos_status[videoTagId].progress_point = divisor * Math.floor(j / divisor); videos_status[videoTagId]._progress_markers[videos_status[videoTagId].progress_point] = false; } // On page DOM, all players currentTime is 0 videos_status[videoTagId].current = 0; // Now we're setting the event listeners. videos[i].addEventListener("play", eventHandler, false); videos[i].addEventListener("pause", eventHandler, false); videos[i].addEventListener("ended", eventHandler, false); videos[i].addEventListener("timeupdate", eventHandler, false); videos[i].addEventListener("ended", eventHandler, false); } })(); </script>
Add the previously created Window Loaded trigger to this tag.
Test the listener
Save all changes in your GTM container and then click the Preview button in the top-right corner of the GTM interface to enable preview mode.
First, in preview mode, you should see that your Custom HTML tag is fired once the page is loaded.
Now, head to your website and interact with the HTML5 video player. Play it, watch for a bit (at least 25% of the video length), and pause. Go back to the Preview mode of GTM. You should see a bunch of video events there.
Click one of them and expand the dataLayer.push. You will see some information about the interaction.
If you see all of this as I do, we can continue.

Data Layer Variables, Custom Event Trigger
Even though we have that video data in the Data Layer, we cannot use it until we create variables for each data point we plan to use. In our case, we are going to use the following:
- video_status
- video_provider
- video_percent
- video_title
We need to create a separate Data Layer Variable for each one. Let’s go to Variables > New > Data Layer Variable and enter the following settings:
Do the same thing for the rest of the data points you want to use (video_provider, video_percent, video_title).
Then we need to create a trigger. Every time a “video” event is pushed to the data layer (and we see it in the preview mode), we want to fire a GA4 tag (that we will create later). That tag will send the video data to Google Analytics. The only way to fire a tag is by using a trigger.
Since we are getting a custom-named “video” event in the preview mode, we have to use a Custom Event trigger in GTM. Go to Triggers > New > Custom Event and enter the word “video” (without quotation marks, all lowercase).
You must enter the word “video” precisely the same way as you see it here:
GA4 event tag
In this tutorial, I presume that you have already installed Google Analytics 4 via Google Tag Manager. If not, read this guide first.
Now, it’s time to send the HTML5 video data to Google Analytics 4 as an event. In GTM, go to Tags > New > Google Analytics > Google Analytics: GA4 event and enter the following settings:
- I entered the measurement ID as a constant variable because I believe it’s a better practice than copy-pasting ID from tag to tag.
- The event’s name is video_, and then I inserted the Data Layer variable that returns the video_status. As a result, the final output of the event name will be video_play, video_pause, video_progress, or video_complete. I’m just trying to follow Google’s naming convention.
- Then I inserted other data layer variables that will be sent as event parameters video_provider, video_percent, and video_title (again, I’m just following Google’s naming convention here)
- Finally, I linked the Custom Event trigger to this GA4 tag.
Test the setup
Click the Preview button in the GTM interface to refresh it. Then go to your website and interact with the HTML5 video player (play, watch for a bit, etc.). Then go back to the GTM preview mode, and you should see this:
Click on any of those events and check if your GA4 tag has fired. Now click another event and do the same thing. Tag fired? That’s good.
Now go to Google Analytics > Admin > DebugView and find your device:
Once you do that, you should see the event stream and several video events (e.g., video_play). Click them and check the values of their parameters.
If everything works as expected, it’s time to publish the container. Hit SUBMIT in the top-right corner and follow all the necessary steps. After that, your changes will go live to all your website visitors.
Register custom dimensions
I don’t know whether this will change in the future, but right now, if you want to use/see video_percent parameter in your regular GA4 reports, you have to register the parameters as a custom dimension.
In GA4, go to Admin > Custom Definitions and click Create Custom Dimension.
Then, enter the following settings to use/see the video_percent parameter.
Dimensions such as Video title or Video provider are already available in GA4 by default. So there’s no need to register them as custom dimensions.
Where can I see video event data in Google Analytics 4?
This is answered in my guide on event tracking with Google Analytics 4. I have a dedicated section that explains which reports you can find video data. So if you want to learn more, go here.
Track HTML5 Video with Google Analytics 4: Final words
That’s it for this time. In this blog post, I explained how to track embedded videos with GTM (that are not YouTube videos). If you try to implement something similar for other players, the principle is quite the same:
- You find a custom auto-event listener built specifically for that video player (if you can’t find it, then most likely, you won’t be able to track the video player)
- You create a Custom JS variable that returns true if the player is on a page
- You fire a listener (Custom HTML tag) only if the player is present on a page
- Then you create Data Layer Variables, Custom Event trigger
- Finally, you create a GA4 event tag that fires on the custom event trigger and sends the values of data layer variables
Also, don’t forget to try the GTM recipe and implement this kind of tracking faster.
23 COMMENTS
Hi Julius,
Is there a way to filter out the autoplay HTML5 videos so that the only videos being tracked are the ones being clicked?
You can achieve that by adjusting trigering options for the tag.
Hello. Thank you for your tutorials. I got as far as the video listener test in GTM preview but I can't get it to fire. In addition to, "cjs – is HTML5 Video on a page" there is another filter appended: _event equals gtm.load that causes it to fail. Do you know where this parameter is coming from to correct/remove?
My fault... the tag is fired on 'Window Loaded' not from the video click itself.
Brilliant intro to Tag Manager, GA4 and HTML5 Video tracking :-)
Will this track videos that auto-play when the page loads?
Is there a way to send data from the application? Push new arguments to the dataLayer for example, like an ID. If so, how can that be accomplished?
Hi Juliius. Great post thanks. Along with all your other posts and videos I have now a basic understanding of GTM and a fairly good setup. I have followed this post and am now tracking video interactions with one issue. I am using JW player as our video player, and I want to pass the video title properly. I assume I need to change this part of the custom HTML tag:
'video_title': decodeURIComponent(e.target.currentSrc.split('/')[e.target.currentSrc.split('/').length - 1])
..to pass in the correct video title coming from JW player, but I have no idea what it should be. Are you able to help with this?
Just google "JW player google tag manager" and you will find a code that is created specifically for JW player
Do you have a solution for vidalytics video percentage watched tracking?
There's a lot of userbase switching from wistia to vidalytics, can't find anything specific for vidalytics.
Thanks for this article.
Can we track HTML video which is getting played on model popup? It is working fine for video which is not on the model popup, but video on model popup is not getting traced in GTM.
Can you share your thought.
Video is also not getting tracked in GTM if we play video on click event.
Is there a reason you chose event names that differ from Google's enhanced measurement events for video engagement (video_start, video_progress, video_complete)?
Just wondering if you didn't think of doing so, or if you considered it but decided to use different names for a particular reason.
Thanks!
Hi Julius, great recipe. But I have one question. When debugging the whole setup there is always a pause event been sent just before the video ends. Is that intended to be this way?
Thank you in advance!
HI, why Can't I find the video event parameter when trying to create a personalized definition? I've already pubblished the changes
Hi Julius,
I followed your instructions to setup tracking for html5 video on my website. The event tag is firing when I preview the changes but it's not showing in real time report in GA4.
All good, yet it will be better to differenciate the initial play event (maybe call it Video Start) with the resume event (after pause).
Otherwise the data is not very useful.
Hi, I have optimized the script and added support for video elements that are added after the page load.
(function () {
const divisor = 25
const videos = {}
const send = (status, e, v) => {
dataLayer.push({
"event": "video",
"video_status": status,
"video_provider": "generic html5 video player",
"video_percent": v.pct,
"video_title": decodeURIComponent(e.target.currentSrc.split("/").pop())
})
}
const eventHandler = (e) => {
const v = videos[e.target.id]
if (!v)
return
switch (e.type) {
case "timeupdate":
if (isNaN(e.target.duration))
break
const pct = Math.floor(100 * e.target.currentTime / e.target.duration)
v.pct = Math.max(v.pct, pct - pct % divisor)
if (v.send.some(x => x >= v.pct))
break
v.send.push(v.pct)
send("progress", e, v)
break
case "play":
send("play", e, v)
break
case "pause":
send("pause", e, v)
break
case "ended":
v.pct = 100
send("complete", e, v)
delete videos[e.target.id]
break
default:
break
}
}
const init = (e) => {
e.id || (e.id = "html5_video_" + Math.random().toString(36).slice(2))
videos[e.id] = { pct: 0, send: [] }
e.addEventListener("play", eventHandler, false)
e.addEventListener("pause", eventHandler, false)
e.addEventListener("timeupdate", eventHandler, false)
e.addEventListener("ended", eventHandler, false)
}
for (const e of document.getElementsByTagName("video"))
init(e)
new MutationObserver(e => {
for (const x of e)
for (const e of x.addedNodes)
if (e instanceof HTMLVideoElement)
init(e)
}).observe(document.body, { childList: true, subtree: true })
})()
Thanks, I will try it. Custom html tag does not support ES6, so I will need to convert it to ES5
Hi Julius, i am wondering how to ignore autorun HTML5 Videos (used in Header and Backgrounds) on a Page?
Update the trigger to exclude those video titles
Hello Julius, thanks a lot for all the work you put into this. It took me a long time to find your blog, and it really saved me. I succesfully followed all the steps and got the video interactions working.
I have one remark. In the GA4 custom dimension there is no way to add the video_percent as it doesn't show up in the drop down. I guess it's allright but just so you know something might have changed there.
Nothing changed. It does not matter that video_percent does not appear. Just register it.