Skip to content

Instantly share code, notes, and snippets.

@ceaksan
Created March 19, 2026 07:28
Show Gist options
  • Select an option

  • Save ceaksan/f0763e7ccf3b5bb8457015ca6c8c5954 to your computer and use it in GitHub Desktop.

Select an option

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.
(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