ab-testing 2 min read

Notify enter viewport

The original requestAnimationFrame helper that fires A/B test events when an element scrolls into view — predecessor to the Intersection Observer rewrite.

rAF-based polling loop with a SUPERSEDED stamp on the right — predecessor to the IntersectionObserver rewrite

Sometimes we are testing a page element or component that, when the page has loaded, lies outside of the viewport. This has the simple consequence that you should not count this page load as a visitor to your test. Only when the element you are testing scrolls into view, the visitor can be added to your test. To accomplish this, we needed some code that keeps track of the elements position on the page with regard to the page’s dimensions.

This one still relies on jQuery, I’ll upload a JS only version soon.

var notifyEnterViewport = function(o) {
		var raf = window.requestAnimationFrame || window.webkitRequestAnimationFrame || window.mozRequestAnimationFrame || window.msRequestAnimationFrame || window.oRequestAnimationFrame,
			l = $(window).height(),
			r = $(window),
			t = r.scrollTop(),
			enter = function(e) {
				e.addClass("MM_inside_viewport"), e.hasClass("MM_outside_viewport") && e.removeClass("MM_outside_viewport")

			},
			exit = function(e) {
				e.addClass("MM_outside_viewport"), e.hasClass("MM_inside_viewport") && e.removeClass("MM_inside_viewport")
			},
			loop = function() {
				var e = r.scrollTop();
				t !== e && (t = e, o.length && $.each(o, function(e) {
					var o = $(this),
						raf = o.height(),
						t = o.offset().top,
						loop = t - r.scrollTop(),
						n = t - r.scrollTop() + raf;
					0 <= loop && n <= l && !o.hasClass("MM_inside_viewport") && (console.log("test: element " + (e + 1) + " scrolled into view"), enter(o)), (n < 0 || l < loop) && !o.hasClass("MM_outside_viewport") && (console.log("test: element " + (e + 1) + " scrolled out of view"), exit(o))
				})), raf(loop)
			};
		raf && loop();
	}
	elementsToCheck = $("div#checkThisElement");
notifyEnterViewport(elementsToCheck);
  • UPDATE!
var notifyEnterViewport = function(el) {
	var w = window,
		raf = w.requestAnimationFrame,
		vpHeight = w.innerHeight,
		lastPgYO = w.pageYOffset,
		scroll = function() {
			var height = el.offsetHeight,
				elOffset = offSet(el),
				top = elOffset.top,
				pTop = top - w.pageYOffset,
				pBottom = pTop + height;
			if (pTop >= 0 && pBottom <= vpHeight && !el.classList.contains('mm_enter')) {
				enter(el);
			}
			if ((pBottom < 0 || pTop > vpHeight) && !el.classList.contains('mm_exit')) {
				exit(el);
			}
		},
		offSet = function(el) {
			var rect = el.getBoundingClientRect(),
				pgxo = w.pageXOffset,
				pgyo = w.pageYOffset;
			return {
				top: rect.top + pgyo,
				left: rect.left + pgxo
			};
		},
		enter = function(el) {
			//console.log("enter: ", el);
			el.classList.add('mm_enter');
			el.classList.remove('mm_exit');
		},
		exit = function(el) {
			//console.log("exit: ", el);
			el.classList.add('mm_exit');
			el.classList.remove('mm_enter');
		},
		loop = function() {
			var PgYO = w.pageYOffset;
			if (lastPgYO === PgYO) {
				raf(loop);
				return;
			} else {
				lastPgYO = PgYO;
				scroll();
				raf(loop);
			}
		};
	if (raf) loop();
};

// call:
notifyEnterViewport(document.querySelector('ul#sub_nav > li.active:not(.first)'));

Update — 2026-05

This rAF-based approach got rewritten properly with the IntersectionObserver API — same end result, dramatically less code, and no scroll-handler tax. If you’re hitting this page from a search result, skip straight to that post.