Track Scroll Milestones
Scroll depth tells you how far users actually read your content. Combined with time on page, it is one of the most useful engagement signals available without any custom development. GTM has a built-in Scroll Depth trigger that handles the heavy lifting.
GTM Built-in Scroll Depth Trigger
Section titled “GTM Built-in Scroll Depth Trigger”-
Enable Scroll Variables
In GTM → Variables → Configure, enable:
- Scroll Depth Threshold
- Scroll Depth Units
- Scroll Direction
-
Create a Scroll Depth Trigger
- Trigger type: Scroll Depth
- Vertical Scroll Depths: check Percentages and enter
25, 50, 75, 90 - This fires: Some Pages if you want to limit to content pages, or All Pages
To limit to blog posts and articles only:
- Fire on: Some Pages
- Condition:
Page Pathmatches RegEx^/blog/|^/articles/
-
Create a GA4 Event Tag
- Tag type: Google Analytics: GA4 Event
- Event name:
scroll_milestone - Parameters:
scroll_depth→{{Scroll Depth Threshold}}scroll_units→{{Scroll Depth Units}}page_path→{{Page Path}}page_title→{{Page Title}}
- Trigger: the Scroll Depth trigger above
-
Test in Preview Mode
Open GTM Preview, visit a long content page, and scroll down slowly. At each 25% milestone, the
gtm.scrollDepthevent should appear in the Summary pane, followed by your GA4 tag firing.
GA4 - scroll_milestone
- Type
- Google Analytics: GA4 Event
- Trigger
- Scroll Depth - 25, 50, 75, 90%
- Variables
-
Scroll Depth ThresholdScroll Depth UnitsPage PathPage Title
Custom JavaScript approach (more control)
Section titled “Custom JavaScript approach (more control)”The built-in trigger is great for simple cases. If you need more control — tracking by pixel offset, excluding short pages, or capturing reading time at each milestone — use a Custom HTML tag instead.
Custom scroll depth tracker with page length filtering and deduplication.
(function() { var milestones = [25, 50, 75, 90]; var fired = {}; var isTracking = false;
// Only track on pages longer than 1500px if (document.documentElement.scrollHeight < 1500) return;
function getScrollPercent() { var scrollTop = window.pageYOffset || document.documentElement.scrollTop; var docHeight = document.documentElement.scrollHeight - window.innerHeight; return docHeight > 0 ? Math.round((scrollTop / docHeight) * 100) : 0; }
function handleScroll() { var percent = getScrollPercent(); milestones.forEach(function(milestone) { if (percent >= milestone && !fired[milestone]) { fired[milestone] = true; window.dataLayer = window.dataLayer || []; window.dataLayer.push({ event: 'scroll_milestone', scroll_depth: milestone, scroll_units: 'percent', page_path: window.location.pathname, page_title: document.title, page_length: document.documentElement.scrollHeight }); } }); }
// Throttle scroll handler var ticking = false; window.addEventListener('scroll', function() { if (!ticking) { requestAnimationFrame(function() { handleScroll(); ticking = false; }); ticking = true; } }, { passive: true });
// Check on load (for short sessions where user doesn't scroll) handleScroll();})();- Add the code as a Custom HTML tag in GTM
- Set the trigger to DOM Ready (so the page height is measurable)
- Optionally filter to specific pages using a Page Path condition
- Create a Custom Event Trigger:
- Trigger type: Custom Event
- Event name:
scroll_milestone
- Create a GA4 Event Tag with the same parameters as shown above
- Test in Preview Mode: scroll down a long page and verify each milestone fires exactly once
Tracking scroll depth in SPAs
Section titled “Tracking scroll depth in SPAs”In single-page apps, the GTM Scroll Depth trigger resets on page navigation automatically when a new page_view event fires. The custom JavaScript approach requires a manual reset on route change:
// Reset scroll tracking on SPA navigationwindow.addEventListener('popstate', function() { fired = {}; // Reset the milestones object});Or push a reset from your router’s navigation hook before the virtual pageview push.
Connecting scroll depth to other events
Section titled “Connecting scroll depth to other events”One powerful pattern is capturing the scroll depth at the time a form submission or CTA click happens — not just as a standalone event:
// On form submit, include current scroll depthwindow.dataLayer.push({ event: 'form_submission', form_name: 'newsletter', scroll_depth_at_submission: getScrollPercent()});This lets you answer “Do users who read 75% of the article convert at a higher rate?”
Test it
Section titled “Test it”- Open GTM Preview, navigate to a long content page (1500px+ height)
- Scroll slowly to 25%, 50%, 75%, and 90%
- Each milestone should appear as a separate
scroll_milestoneevent in the Summary pane - Verify Tags Fired shows your GA4 tag for each
- In GA4 DebugView, verify
scroll_depthparameter shows the correct percentage
Common gotchas
Section titled “Common gotchas”Scroll fires twice. If you have both the built-in trigger and a custom JS implementation, you will get duplicates. Pick one approach.
100% scroll never fires. This is by design — most of the time, users cannot scroll to the very bottom of the page because the viewport height prevents it. Use 90% as your “read to completion” proxy.
Short pages inflate engagement. A 300px page will trigger all milestones in a split second. Filter to pages above a minimum height using a Page Path condition or the scrollHeight check in the custom approach.