Created
March 19, 2026 07:28
-
-
Save ceaksan/f0763e7ccf3b5bb8457015ca6c8c5954 to your computer and use it in GitHub Desktop.
Content-scoped scroll depth tracking with engagement classification. Measures how users actually consume content (engaged / scanned / skipped) using dwell time, velocity tracking, and dynamic height versioning. Works with GTM dataLayer, no dependencies.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| (function (root) { | |
| 'use strict'; | |
| var DEFAULTS = { | |
| contentSelector: '.main-content', | |
| thresholds: [25, 50, 75, 100], | |
| dwellThreshold: 2000, | |
| skipThreshold: 600, | |
| velocityThreshold: 2.5, | |
| skipVelocityRatio: 3, | |
| reEntry: 'önce', | |
| pageType: 'default', | |
| debug: false, | |
| }; | |
| var CONFIG = {}; | |
| var heightVersion = 1; | |
| var knownHeight = 0; | |
| var cachedTop = 0; | |
| var lastScrollY = 0; | |
| var lastScrollTime = 0; | |
| var rafPending = false; | |
| var paused = false; | |
| var zones = {}; | |
| var resizeObserver = null; | |
| function getContentEl() { | |
| return document.querySelector(CONFIG.contentSelector) || null; | |
| } | |
| function refreshContentCache() { | |
| var el = getContentEl(); | |
| if (el) { | |
| cachedTop = el.offsetTop; | |
| knownHeight = el.offsetHeight; | |
| } else { | |
| cachedTop = 0; | |
| knownHeight = document.documentElement.scrollHeight; | |
| } | |
| } | |
| function getScrollPct() { | |
| if (knownHeight === 0) return 0; | |
| var scrolled = window.scrollY + window.innerHeight - cachedTop; | |
| return Math.min(100, Math.max(0, Math.round((scrolled / knownHeight) * 100))); | |
| } | |
| function avg(arr) { | |
| if (!arr.length) return 0; | |
| return arr.reduce(function (a, b) { return a + b; }, 0) / arr.length; | |
| } | |
| function ts() { return Date.now(); } | |
| function initZones() { | |
| zones = {}; | |
| CONFIG.thresholds.forEach(function (pct) { | |
| zones[pct] = { | |
| inZone: false, entered: null, dwelt: 0, | |
| entryVelocity: 0, velocitySamples: [], | |
| firedVersions: {}, visitCountTotal: 0, | |
| visitCountVersion: 0, _lastFiredVersion: null, | |
| }; | |
| }); | |
| } | |
| function pauseDwellTimers() { | |
| if (paused) return; | |
| paused = true; | |
| var t = ts(); | |
| CONFIG.thresholds.forEach(function (pct) { | |
| var zone = zones[pct]; | |
| if (zone.inZone && zone.entered !== null) { | |
| zone.dwelt += t - zone.entered; | |
| zone.entered = null; | |
| } | |
| }); | |
| } | |
| function resumeDwellTimers() { | |
| if (!paused) return; | |
| paused = false; | |
| var t = ts(); | |
| CONFIG.thresholds.forEach(function (pct) { | |
| var zone = zones[pct]; | |
| if (zone.inZone) zone.entered = t; | |
| }); | |
| } | |
| function onVisibilityChange() { | |
| if (document.hidden) pauseDwellTimers(); | |
| else resumeDwellTimers(); | |
| } | |
| function canFire(zone) { | |
| var vKey = 'v' + heightVersion; | |
| if (CONFIG.reEntry === 'önce') | |
| return Object.keys(zone.firedVersions).length === 0; | |
| if (CONFIG.reEntry === 'per_height_version') | |
| return !zone.firedVersions[vKey]; | |
| return true; | |
| } | |
| function fireEvent(pct, zone, scrollType) { | |
| var vKey = 'v' + heightVersion; | |
| zone.firedVersions[vKey] = true; | |
| zone.visitCountTotal++; | |
| if (zone._lastFiredVersion !== vKey) { | |
| zone.visitCountVersion = 0; | |
| zone._lastFiredVersion = vKey; | |
| } | |
| zone.visitCountVersion++; | |
| var payload = { | |
| event: 'content_scroll', | |
| scroll_pct: pct, | |
| scroll_type: scrollType, | |
| height_version: heightVersion, | |
| content_height_px: knownHeight, | |
| visit_count_total: zone.visitCountTotal, | |
| visit_count_version: zone.visitCountVersion, | |
| dwell_ms: Math.round(zone.dwelt), | |
| avg_velocity: parseFloat(avg(zone.velocitySamples).toFixed(2)), | |
| reentry_mode: CONFIG.reEntry, | |
| content_selector: CONFIG.contentSelector, | |
| page_type: CONFIG.pageType, | |
| }; | |
| root.dataLayer = root.dataLayer || []; | |
| root.dataLayer.push(payload); | |
| } | |
| function calculate() { | |
| rafPending = false; | |
| if (paused) return; | |
| var t = ts(); | |
| var currentY = window.scrollY; | |
| var deltaY = Math.abs(currentY - lastScrollY); | |
| var deltaT = t - lastScrollTime || 1; | |
| var velocity = deltaY / deltaT; | |
| var currentPct = getScrollPct(); | |
| lastScrollY = currentY; | |
| lastScrollTime = t; | |
| CONFIG.thresholds.forEach(function (pct) { | |
| var zone = zones[pct]; | |
| var isInZone = currentPct >= pct; | |
| // Zone'a giriş | |
| if (isInZone && !zone.inZone) { | |
| zone.inZone = true; | |
| zone.entered = t; | |
| zone.entryVelocity = velocity; | |
| zone.velocitySamples = [velocity]; | |
| } | |
| // Zone'da kalma: velocity sampling | |
| if (isInZone && zone.inZone) { | |
| zone.velocitySamples.push(velocity); | |
| if (zone.velocitySamples.length > 20) zone.velocitySamples.shift(); | |
| } | |
| // Zone'dan çıkış: hızlı çıkış = skipped | |
| if (!isInZone && zone.inZone) { | |
| zone.inZone = false; | |
| if (zone.entered !== null) { | |
| zone.dwelt += t - zone.entered; | |
| zone.entered = null; | |
| } | |
| if (canFire(zone) && zone.dwelt < CONFIG.skipThreshold) { | |
| fireEvent(pct, zone, 'skipped'); | |
| } | |
| } | |
| // Zone'da ve dwell threshold'u geçti: sınıflandır | |
| if (isInZone && canFire(zone)) { | |
| var liveDwell = zone.dwelt + (zone.entered !== null ? t - zone.entered : 0); | |
| if (liveDwell >= CONFIG.dwellThreshold) { | |
| var avgVel = avg(zone.velocitySamples); | |
| var highEntry = zone.entryVelocity > CONFIG.velocityThreshold * CONFIG.skipVelocityRatio; | |
| var scrollType = (highEntry && avgVel > CONFIG.velocityThreshold) | |
| ? 'skipped' | |
| : avgVel <= CONFIG.velocityThreshold | |
| ? 'engaged' | |
| : 'scanned'; | |
| fireEvent(pct, zone, scrollType); | |
| } | |
| } | |
| }); | |
| } | |
| function onScroll() { | |
| if (!rafPending) { | |
| rafPending = true; | |
| requestAnimationFrame(calculate); | |
| } | |
| } | |
| function initResizeObserver() { | |
| if (!root.ResizeObserver) return; | |
| var target = getContentEl() || document.body; | |
| resizeObserver = new ResizeObserver(function () { | |
| var prevHeight = knownHeight; | |
| refreshContentCache(); | |
| if (knownHeight !== prevHeight && prevHeight !== 0) { | |
| heightVersion++; | |
| requestAnimationFrame(calculate); | |
| } | |
| }); | |
| resizeObserver.observe(target); | |
| } | |
| function init(userConfig) { | |
| destroy(); | |
| CONFIG = Object.assign({}, DEFAULTS, userConfig || {}); | |
| heightVersion = 1; | |
| lastScrollTime = ts(); | |
| refreshContentCache(); | |
| initZones(); | |
| initResizeObserver(); | |
| window.addEventListener('scroll', onScroll, { passive: true }); | |
| document.addEventListener('visibilitychange', onVisibilityChange); | |
| } | |
| function destroy() { | |
| window.removeEventListener('scroll', onScroll); | |
| document.removeEventListener('visibilitychange', onVisibilityChange); | |
| if (resizeObserver) { resizeObserver.disconnect(); resizeObserver = null; } | |
| paused = false; | |
| } | |
| root.ScrollTracker = { init: init, destroy: destroy }; | |
| }(window)); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment