Dynamic Scroll Offset Via Custom Properties

Published
Categories

Maybe you have a large project where there is a sticky header on some pages and not others, or the sticky header changes size based on content. By default if you use an anchor link to jump down to a section of the page it will line up the target element directly to the top of the viewport; This can be a problem with sticky elements since they will overlap the thing you just scrolled to. scroll-margin exists to solve this, however just that will not be enough in cases where we don’t know the size or if the sticky item will be there at all. In order to account for these unknowns, we can combine scroll-margin with custom properties and a little bit of simple JavaScript.

This can also be used to easily stack multiple sticky items together and all have them positioned without any overlap.

JavaScript

All we’re going to do with JavaScript is simply get the height of any element that we need and save that value as a custom property on the root element of the page so that it is available to any child element’s CSS.

  • To easily support multiple elements, we look for any elements with an attribute of data-dynamic-property. We then use the value of this attribute as the name of the custom property.
  • Since the header may change size or styles altogether at different breakpoints, we will use a ResizeObserver to watch each element and update the custom property as needed.
const elements = document.querySelectorAll("[data-dynamic-property]");

if ("ResizeObserver" in window) {
	window.customPropertyResizeObserver = new ResizeObserver((entries) => {
		for (let entry of entries) {
			const el = entry.target;

			const propertySuffix = el.dataset.dynamicProperty
				? el.dataset.dynamicProperty
				: false;

			const rect = el.getBoundingClientRect();

			const height = rect.height;

			if (propertySuffix) {
				document.documentElement.style.setProperty(
					`--ds-space--${propertySuffix}`,
					`${height}px`
				);
			}
		}
	});

	elements.forEach((element) => {
		window.customPropertyResizeObserver.observe(element);
	});
}Code language: JavaScript (javascript)

From here we can watch new elements simply by adding our data attribute to an element with the name of the custom property we want to generate. For example:

<!-- 2. generated custom property for our watched element -->
<html style="--ds-space--site-header-height: 138px;">
	<body>
		<!-- 1. element we want to watch -->
		<header class="ds-c-site-header" data-dynamic-property="site-header-height"></header>
		<main></main>
	</body>
</html>
Code language: HTML, XML (xml)

CSS

Once we have values assigned as custom properties all we need to do is reference it in our scroll-margin:

/* offset the scroll of any item with an id, since we might link to it */
[id] {
	scroll-margin-block-start: var(--ds-space--site-header-height);
}

/* in some cases, we may have a second stacked sticky item,
 * which we can handle with calc and fallbacks of 0px
 */
@media (min-width: 768px) {
	[id] {
		scroll-margin-block-start: calc(
			var(--ds-space--site-header-height, 0px) +
				var(--ds-space--subpage-nav-height, 0px)
		);
	}
}Code language: CSS (css)

Now all of our items with an ID attribute will line up perfectly with any sticky elements we are watching when anchors are used to jump to particular elements. Accounting for new sticky elements is trivial, since all we need to do is add a data attribute into the HTML and then reference the new custom property in our CSS.

One last note: If using one of these dynamic properties in calc(), make sure to specify a fallback of 0px so that if the element does not exist in the page (and thus no custom property is generated), the calc function will still have a number value. If you do not specify a fallback it will use initial, which causes calc() to not work since it cannot be treated as a number.