<- Back to blog
How-to9 min readUpdated May 1, 2026

How to Track Scroll Depth Without Cookies

A privacy-friendly guide to tracking scroll depth on your articles and landing pages. HTML snippet, throttled JavaScript, and a workflow for using the data.

scroll depth trackingcookieless scroll trackingengagement analyticsarticle read trackingscroll percentage javascriptcontent engagement metrics

TL;DR

  • 1.Pageviews tell you who arrived; scroll depth tells you who stayed.
  • 2.Send a single event per visit — `scroll_depth` with the deepest percentage reached, not one event per scroll milestone.
  • 3.Throttle the scroll listener to once per ~250ms; raw scroll events fire dozens of times per second.
  • 4.Useful milestones: 25%, 50%, 75%, 100%. Most blog posts lose half their readers between 25% and 50%.
  • 5.Pair scroll depth with time-on-page to separate engaged readers from accidental scrollers.

Why scroll depth, and why now

A pageview is a low bar. Someone clicked a link and your page loaded. They might have stayed for ten minutes, or they might have closed the tab in two seconds. Scroll depth is the cheapest, least-intrusive way to tell those two visitors apart.

For long-form content — blog posts, documentation pages, landing pages with a real story — scroll depth is one of the few engagement metrics that survives a privacy-friendly setup. You do not need cookies, you do not need a session ID, you do not need to fingerprint the visitor. You just need to know how far they scrolled.

This guide walks through a clean implementation. It uses one event per visit, throttles the listener properly, and avoids the most common pitfalls (over-counting, double-firing, lost events on tab-close).

Step 1: install Sleek

The script is privacy-friendly and cookieless, so the scroll-depth event you fire next inherits the same compliance posture — no consent banner needed.

index.html
<script async src="https://getsleek.io/v1.js" data-site="YOUR_SITE_KEY"></script>

Step 2: pick a strategy — milestones or final-only

There are two reasonable shapes for scroll-depth data. Each has trade-offs.

For most teams, final-only is enough. You can still answer "what percentage of readers got past 50%" by filtering on the `max_percent` property. We will use the final-only pattern in this guide.

  • Milestone events: fire `scroll_depth` at 25%, 50%, 75%, and 100% as the visitor crosses each threshold. You get a funnel — "X% of readers reached 50%" — but you fire 4 events per engaged reader.
  • Final-only event: track the deepest percentage reached during the visit and send a single `scroll_depth` event when the visitor leaves. You get one event per visit, which is cleaner and cheaper, but you lose the in-between funnel.

Step 3: write the throttled scroll handler

The scroll event fires every time the user moves the wheel — easily 100+ times per second. You do not want to do work in the listener that often, especially layout reads like `document.documentElement.scrollHeight`. Throttle to roughly 4 times per second using a `requestAnimationFrame` debounce.

scroll-depth.js
let maxPercent = 0
let ticking = false

function calculateScrollPercent() {
  const doc = document.documentElement
  const scrollTop = window.scrollY || doc.scrollTop
  const viewport = window.innerHeight
  const fullHeight = doc.scrollHeight

  // Page shorter than viewport = always 100%.
  if (fullHeight <= viewport) return 100

  const percent = Math.round(((scrollTop + viewport) / fullHeight) * 100)
  return Math.min(100, percent)
}

function onScroll() {
  if (ticking) return
  ticking = true
  requestAnimationFrame(() => {
    const current = calculateScrollPercent()
    if (current > maxPercent) maxPercent = current
    ticking = false
  })
}

window.addEventListener('scroll', onScroll, { passive: true })

Step 4: send the final event when the visitor leaves

You want to send the event at the latest possible moment — right before the page is unloaded — so the deepest scroll position is captured. The event must use `navigator.sendBeacon` (which Sleek's SDK does internally) so the request survives the page tear-down.

The cleanest hook is `visibilitychange` to `hidden`, with `pagehide` as a fallback for older browsers. Do not use `unload` — modern browsers do not fire it reliably anymore.

scroll-depth.js
let sent = false

function reportScrollDepth() {
  if (sent) return
  sent = true

  window.sleek('track', 'scroll_depth', {
    max_percent: maxPercent,
    page: window.location.pathname,
    bucket: maxPercent >= 90 ? '90-100'
          : maxPercent >= 75 ? '75-89'
          : maxPercent >= 50 ? '50-74'
          : maxPercent >= 25 ? '25-49'
          : '0-24',
  })
}

document.addEventListener('visibilitychange', () => {
  if (document.visibilityState === 'hidden') reportScrollDepth()
})
window.addEventListener('pagehide', reportScrollDepth)
tip:The `bucket` property is what you will group by in the dashboard. Storing it in the event saves you from having to do the math in your reporting layer.

Step 5: account for very short pages and SPAs

On a page that is shorter than the viewport, the visitor cannot scroll — the helper above returns 100% immediately. That is honest: there was nothing to scroll past. You will see a lot of 100% events on landing pages and that is correct.

On a single-page app, page-to-page navigation does not trigger the `pagehide` event. You need to fire the scroll event when the route changes. Hook into your router — for Next.js App Router, use `usePathname` in a `useEffect`; for React Router, listen to the location changes — and call `reportScrollDepth()` plus reset `maxPercent = 0` and `sent = false` for the next page.

Step 6: read the data

In Sleek's Events tab, group `scroll_depth` events by `bucket`. The default report you want is: "for each bucket, how many readers ended up there?" That is your overall engagement funnel.

A more interesting report: filter to a specific blog post (set `page` filter), then group by `bucket`. Now you can see, for that one post, how readers progressed. Compare across your top 10 posts and you will quickly find the one where readers bail at 30% — that is your refactor candidate.

Pair this with time-on-page (Sleek tracks this automatically) and you can spot the difference between "engaged reader" (high scroll, high time) and "click-and-scroll-bot" (high scroll, very low time).

Pitfalls and gotchas

  • Forgetting to throttle — your scroll handler runs hundreds of times per second and tanks scroll smoothness on mobile.
  • Firing on every scroll milestone instead of once per visit — quadruples your event count for no extra insight.
  • Using `unload` instead of `visibilitychange` — modern browsers ignore `unload` for performance reasons.
  • Forgetting to reset state on SPA navigation — every subsequent "page" will show a stale scroll percent.
  • Counting clicked-into-iframe scrolls as parent-page scrolls — guard with `if (event.target === document)` if you embed a lot of iframes.

Frequently asked questions

How accurate is scroll-depth tracking?

It is directional, not exact. The percentage is rounded, the viewport definition varies between browsers, and short pages always read as 100%. Treat the buckets (`25-49`, `50-74`, etc.) as the unit of analysis rather than the exact number. Year-over-year comparisons within the same site are reliable; cross-site comparisons are not.

Does this work for infinite-scroll pages?

Sort of. Each time new content loads, the page height grows and the percentage you have already scrolled drops. The right pattern for infinite-scroll is to track scroll *distance* in pixels or items-loaded-and-passed, not percent. Use the listener structure from this guide but swap the calculation.

Will scroll tracking slow down my site?

Not if you throttle properly. The script in this guide does layout reads at most ~4 times per second using `requestAnimationFrame`, which is well under the threshold where users feel jank. The total CPU cost is in the microseconds per second range.

Do I need cookies for scroll-depth tracking?

No. The event in this guide does not store anything in the browser, does not use a session ID, and does not collect personal data. Under GDPR and ePrivacy you do not need a consent banner to fire it.

Why is my 100% bucket bigger than I expected?

Almost always because you have a lot of short pages — landing pages, redirects, error pages — that fit in a single viewport. Filter your report to a specific long-form page (a blog post) and the distribution will look healthier.

How do scroll depth and time-on-page differ?

Scroll depth measures distance through content; time-on-page measures attention. A reader who skims to the end in 8 seconds has high scroll, low time. A reader who reads carefully and stops at 60% has low scroll, high time. Both are useful — together they paint a real engagement picture.

Can I A/B test based on scroll depth?

Yes. Treat "reached 75%+" as a soft conversion and compare the rate between variants. It is noisier than form-submit conversions, so you need a bigger sample size, but for content tests where there is no obvious primary metric it is one of the better proxies.

Track your own growth loop

Sleek Analytics gives you visitors, sources, pages, devices, and real-time behavior with one lightweight script. No cookies, no GDPR banners.

Related reading