• Courses
    • Paid courses
    • Course bundles
    • Free courses
  • Blog
  • Resources
    • Youtube channel
    • E-books and Guides
    • GTM Recipes
    • View All Resources
    • GTM Community
    • GA4 community
  • Services
  • About
    • About
    • Contact
  • Login
  • Courses
    • Paid courses
    • Course bundles
    • Free courses
  • Blog
  • Resources
    • Youtube channel
    • E-books and Guides
    • GTM Recipes
    • View All Resources
    • GTM Community
    • GA4 community
  • Services
  • About
    • About
    • Contact
  • Login

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.

Subscribe and Get the Ebook - working with reports in ga4

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.

custom definitions in google analytics 4

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.

Julius Fedorovicius
In Google Analytics Tips Google Tag Manager Tips
23 COMMENTS
Cory
  • Jun 11 2021
  • Reply

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?

    Mike
    • Sep 8 2021
    • Reply

    You can achieve that by adjusting trigering options for the tag.

Billiam
  • Dec 2 2021
  • Reply

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?

    Billiam
    • Dec 3 2021
    • Reply

    My fault... the tag is fired on 'Window Loaded' not from the video click itself.

Asa
  • Dec 9 2021
  • Reply

Brilliant intro to Tag Manager, GA4 and HTML5 Video tracking :-)

Chris
  • Feb 26 2022
  • Reply

Will this track videos that auto-play when the page loads?

JM
  • Mar 24 2022
  • Reply

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?

Liam Fletcher
  • Apr 5 2022
  • Reply

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?

    Julius Fedorovicius
    • Apr 13 2022
    • Reply

    Just google "JW player google tag manager" and you will find a code that is created specifically for JW player

Hafiz Ahmad Ashfaq
  • Jun 6 2022
  • Reply

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.

Pritam Choudhary
  • Jun 22 2022
  • Reply

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.

Pritam Choudhary
  • Jun 22 2022
  • Reply

Video is also not getting tracked in GTM if we play video on click event.

Philip DiPatrizio
  • Jun 23 2022
  • Reply

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!

Vivien
  • Mar 28 2023
  • Reply

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!

Jacopo
  • Jun 19 2023
  • Reply

HI, why Can't I find the video event parameter when trying to create a personalized definition? I've already pubblished the changes

Michael
  • Oct 19 2023
  • Reply

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.

Cycle
  • Apr 9 2024
  • Reply

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.

Marius Linders
  • Oct 9 2024
  • Reply

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 })
})()

    Julius Fedorovicius
    • Oct 9 2024
    • Reply

    Thanks, I will try it. Custom html tag does not support ES6, so I will need to convert it to ES5

Chris Winter
  • Nov 21 2024
  • Reply

Hi Julius, i am wondering how to ignore autorun HTML5 Videos (used in Header and Backgrounds) on a Page?

    Julius Fedorovicius
    • Nov 21 2024
    • Reply

    Update the trigger to exclude those video titles

Nick
  • Apr 5 2025
  • Reply

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.

    Julius Fedorovicius
    • Apr 5 2025
    • Reply

    Nothing changed. It does not matter that video_percent does not appear. Just register it.

Leave a comment Cancel reply

Your email address will not be published. Required fields are marked *


 

Hi, I'm Julius Fedorovicius and I'm here to help you learn Google Tag Manager and Google Analytics. Join thousands of other digital marketers and digital analysts in this exciting journey. Read more
Analytics Mania
  • Google Tag Manager Courses
  • Google Tag Manager Recipes
  • Google Tag Manager Resources
  • Google Tag Manager Community
  • Login to courses
Follow Analytics Mania
  • Subscribe to newsletter
Recent Posts
  • Google Tag Manager Server-Side Tagging with Stape
  • How to Preserve Ad Click IDs with Server-side Tagging
  • LinkedIn Conversions API with Google Tag Manager. The Guide.
Analytics Mania - Google Tag Manager and Google Analytics Blog | Privacy Policy
Manage Cookie Settings