EN ES
Mastering Scroll Tracking: Handling Hidden and Dynamic Content with JavaScript

Mastering Scroll Tracking: Handling Hidden and Dynamic Content with JavaScript

Imagine a form that dynamically shows different sections based on user input, like selecting a plan, a feature or an option in a config view…

When we need to track how far a user has scrolled on a page. There are cases that some elements change the page height when the user does something, and we need to react to those changes. Sounds easy, right? Well, not always. However, we face a problem: hidden elements are not positioned in the viewport, so they don’t have a y coordinate, and we can’t change the scroll behavior dynamically. To achieve this, we need to estimate it based on the siblings’ positions and their heights.

For example, when the user selects a category, the page dynamically shows or hides content based on the user’s selection.

Then we need two things:

  • Get the y position of the element (relatively to the page)
  • Calculate the offset and assume that the element is visible for scroll calculation

The point 2 ensures that the viewport height is absolute and regardless of whether the sections are hidden or not.

In general:

For retrieving the relative y from the view port we can track the sibling’s height, and parents offset:

/**
 * Calculates the total vertical offset of an element relative to the document body.
 * This includes the heights of all previous siblings and parent element offsets.
 *
 * @param {HTMLElement} element - The DOM element to calculate offset for
 * @returns {number} The total vertical offset in pixels
 */
function calculateHiddenY(element) {
	let totalOffset = 0;
	let currentElement = element;

	// Walk through all previous siblings and add their heights
	while (currentElement.previousElementSibling) {
		currentElement = currentElement.previousElementSibling;
		totalOffset += currentElement.offsetHeight;
	}

	// Add parent offsets until we reach the document body
	currentElement = element.parentElement;
	while (currentElement && currentElement !== document.body) {
		totalOffset += currentElement.offsetTop;
		currentElement = currentElement.parentElement;
	}

	return totalOffset;
}

With calculateHiddenY we can get the y position regardless if the elements are hidden or not. Now we need to get the offset, This allows us to correctly calculate scroll ranges and percentages, even for hidden elements.

/**
 * Calculates the offset height of an element based on its position relative to the viewport.
 * Returns the element's height if it's in view, otherwise returns 0.
 *
 * @param {HTMLElement} element - The DOM element to calculate offset for
 * @param {number} scrollTop - The current scroll position from top of the document
 * @returns {number} The offset height in pixels if element is in viewport, 0 otherwise
 */
function calculateOffset(element, scrollTop) {
	const posY = calculateHiddenY(element);
	const windowHeight = window.innerHeight;

	if (posY < scrollTop + windowHeight) {
		return element.offsetHeight;
	}
	return 0;
}

Now we can track it!

We can estimate all the hidden elements, that have a hidden class and track the scroll in increments of 10%. We can do this:

/*
* Track the user's scroll and queue the data to report to the backend at 10%, 20%, 30%, and so on.
*
* @param {Object} event - The event object from the event listener.
*/
function trackUserScroll(event) {
	const element = event.target.scrollingElement;
	const hiddenElements = document.getElementsByClassName("hidden");
	let hiddenOffset = 0;

	// Calculate how much hidden content has been "scrolled past"
	for (let i = 0; i < hiddenElements.length; i++) {
		hiddenOffset += calculateOffset(hiddenElements[i], element.scrollTop);
	}

	// Calculate the scroll percentage including hidden content
	const visibleScrolled = element.scrollTop;
	const totalScrollable = element.scrollHeight - element.clientHeight;
	// This calculates multiples of 10
	const scrollPercentage = Math.floor((visibleScrolled + hiddenOffset) / totalScrollable * 10) * 10;

	// do something with the scroll data
	//...

	// Save in localStorage for example
	localStorage.setItem("scroll", scrollPercentage);
}

// Track user scroll only for the current view
document.addEventListener("scroll", trackUserScroll);

// Clean up event listener
window.addEventListener("beforeunload", () => {
	document.removeEventListener("scroll", trackUserScroll);
});

Notice that we remove the event listener when the page is unloaded, this ensures that the scroll tracking only should work in the current view.

Send the data for analytics data can be a good usage, for example function sendReportToBackend can be defined as follows:

async function sendReportToBackend(url, args) {
	try {
		const response = await fetch(url, {
			method: 'POST',
			body: JSON.stringify(queue_str),
			credentials: 'include',
			headers: {
				"Content-type": "application/json; charset=UTF-8"
			}
		});

		if (response.ok) {
			const data = await response.json();
			return data;
		} else {
			log(`Error response from server: ${response.status}`);
			return { message: "error" };
		}
	} catch (error) {
		log("Error processing queued request", error);
		return { message: "error" };
	}
}

And in the trackUserScroll function we can do this:

sendReportToBackend("/someview/track-scroll", { scroll: scrollPercentage });

This will send a JSON data to the backend in the endpoint someview/track-scroll.

In this case can be important to reduce the backend report frequency, we can use something like a debounce function:

function debounce(func, wait) {
	let timeout;
	return function executedFunction(...args) {
		const later = () => {
			clearTimeout(timeout);
			func(...args);
		};
		clearTimeout(timeout);
		timeout = setTimeout(later, wait);
	};
}

Then:

debounce(() => sendReportToBackend("/someview/track-scroll", { scroll: scrollPercentage }), 500); // Every 500 ms, for example

// We can define a custom function to avoid recreating the debounce on each scroll
const debouncedSendScroll = debounce((scrollPercentage) => {
	sendReportToBackend("/someview/track-scroll", { scroll: scrollPercentage });
}, 500);

Another example. If our elements that we need to estimate has a [data-section] attribute, and the class for hidden it is hidden, we can do this:

function trackUserScroll(event) {
	const element = event.target.scrollingElement;
	const hiddenDrawingsSections = document.querySelectorAll("[data-section].hidden"); // Select a specific selector
	let hiddenOffset = 0;

	// Calculate how much hidden content has been "scrolled past"
	for (let i = 0; i < hiddenDrawingsSections.length; i++) {
		hiddenOffset += calculateOffset(hiddenDrawingsSections[i], element.scrollTop);
	}

	// Calculate the scroll percentage including hidden content
	const visibleScrolled = element.scrollTop;
	const totalScrollable = element.scrollHeight - element.clientHeight;
	const scrollPercentage = Math.floor((visibleScrolled + hiddenOffset) / totalScrollable * 10) * 10;

	debouncedSendScroll(scrollPercentage);
}

With these techniques, you can reliably track user engagement even when your page dynamically changes.