Just Simply! Smooth Scrolling: Part Two

As promised in part one, there's a universal truth that can solve nearly everything.

Just simply let the browser do what it does. Do not change how events are processed by the browser, only use JavaScript for minor course corrections, and only when you have to.

Just to clarify, there is no satire or sarcasm here.

Scroll Behavior

Globally supported this link leads to an external website on the latest version of all major browsers (including Safari + IOS), Scroll Behavior this link leads to an external website support will only get better from here. Set it and forget it.

html {
  --scroll-behavior: smooth;
}

@media (prefers-reduced-motion: reduce) {
  html {
    --scroll-behavior: auto;
  }
}
html {
  scroll-behavior: var(--scroll-behavior);
}

Scroll Padding

This CSS property can gracefully solve the problem posed by a sticky navigation bar.

html {
  --scroll-padding-top: var(--mobile-sticky-banner-height);
}

/* perhaps some media queries to bump the value? */
html {
  scroll-padding-top: var(--scroll-padding-top);
}

What if my sticky element's height can't be predicted?

This can be a compelling use case for a bit of JavaScript that can strategically tweak the scroll-padding-top property.

(() => {
  function updateScrollPaddingTop() {
    const sticky = document.querySelector('.sticky');
    if (sticky) {
      const sticky_height = sticky.getBoundingClientRect().height;
      document.documentElement.style.scrollPaddingTop = sticky_height + "px"; 
    }
  }

  // Initialize the property on page load in-case a user is visiting with a fragment in the URL.
  updateScrollPaddingTop();

  // Re-calculate the property when jump link is clicked, but before scrolling starts.
  const jump_links = get_jump_links();
  jump_links.forEach(jump_link => {
    jump_link.addEventListener('click', e => {
      updateScrollPaddingTop();
    });
  });
})();

What if I have multiple sticky elements that conditionally stack?

For more advanced and loosely coupled needs, the scroll-padding-top tweak can be expanded a bit.  One use case that I've seen is that there is the normal sticky navigation element, and for authenticated users, there might be an administrative toolbar that is also sticky.

// Sticky banner component pushes its callback into the array.
(() => {
  const scroll_padding_factors = window.scroll_padding_factors || [];
  scroll_padding_factors.push(() => {
    const sticky = document.querySelector('.sticky');
    if (sticky) {
      return sticky.getBoundingClientRect().height;
    }
    return 0;
  });
})();

// Admin menu component pushes its callback into the array.
(() => {
  const scroll_padding_factors = window.scroll_padding_factors || [];
  scroll_padding_factors.push(() => {
    const admin_menu = document.querySelector('.admin_menu');
    if (admin_menu) {
      return sticky.getBoundingClientRect().height;
    }
    return 0;
  });
})();

(() => {
  const scroll_padding_factors = window.scroll_padding_factors || [];
  
  function updateScrollPaddingTop() {

    // Add up all of the factors to find the cumulative padding required.
    const scroll_padding_top = scroll_padding_factors.reduce((sum, factor) => sum + factor(), 0);
    if (scroll_padding_top) {
      document.documentElement.style.scrollPaddingTop = scroll_padding_top + "px";
    }
  }

  // Initialize the property on page load in-case a user is visiting with a fragment in the URL.
  updateScrollPaddingTop();

  // Re-calculate the property when jump link is clicked, but before scrolling starts.
  const jump_links = get_jump_links();
  jump_links.forEach(jump_link => {
    jump_link.addEventListener('click', e => {
      updateScrollPaddingTop();
    });
  });
})();

What if my sticky banner changes height while scrolling?

Unfortunately, I don't have a universally bullet-proof answer for this one.  If the changes in height are predictable, there's no limit to what tweaks can happen with javascript.

Browser history management

The key to the approach is that there is no manual history management.  Browsers are free to treat jump-links as they do natively.  As far as I am aware, there are no hard and fast rules to how browsers manage this, so users will likely be pleased to find that there aren't any weird customized surprises awaiting them when they start clicking back, forward, and sideways through the page.

How to deal with jumping to elements that aren't accessible?

This isn't a trivial problem to solve, but it can be managed through strategically crafted interactive components such as accordions, tabs, etc... Let's assume for a moment that your design system is based on Web Components!

class InteractiveElement extends HTMLElement {
  activate() {
    // Expand an accordion, activate a tab, etc...
  }
}

Now that we have an InteractiveElement that is the base for everything that an element could be hidden inside of, it becomes relatively easy to make the element accessible before jumping to it!

(() => {
  function autoActivateElementPath(element) {
    const interactive_path = [];
    do {
      if (element instanceof InteractiveElement) {
        interactive_path.unshift(element);
      }
      element = element.parentElement;
    }
    while (element);

    // From outside-in make each element accessible.
    interactive_path.forEach(element => {
      element.activate();
    });
  }

  // Auto-activate on page load in-case a user is visiting with a fragment in the URL.
  if (location.hash) {
    const target = document.querySelector(location.hash);
    if (target) {
      autoActivateElementPath(target);
    }
  }

  // Auto-activate the interactive element path up to the jump target.
  const jump_links = get_jump_links();
  jump_links.forEach(jump_link => {
    jump_link.addEventListener('click', e => {
      const target = document.getElementById(e.target.getAttribute('href'));
      if (target) {
        autoActivateElementPath(target);
      }
    });
  });
})();

What if my accordions animate while opening?

The best way that I've found to work around this is to provide a flag to the mechanism used to activate the components that disables animation (or in some cases, reduces the animation duration to one millisecond).  We can bend the golden rule here without breaking it by killing off the original click event, but then re-dispatching a new one.

(() => {

  function autoActivateElementPath(element) {
    const interactive_path = [];
    do {
      if (element instanceof InteractiveElement) {
        interactive_path.unshift(element);
      }
      element = element.parentElement;
    }
    while (element);

    // From outside-in make each element accessible.
    interactive_path.forEach(element => {
      element.activate({
        disable_animations: true
      });
    });
  }

  // Auto-activate the interactive element path up to the jump target.
  const jump_links = get_jump_links();
  jump_links.forEach(jump_link => {
    jump_link.addEventListener('click', e => {
      const target = document.getElementById(e.target.getAttribute('href'));
      if (target) {

        // Don't worry, we'll re-trigger a click event that we won't kill off.
        e.preventDefault();
        autoActivateElementPath(target, true);

        // Re-trigger a "native" click event after we're sure animations are complete.
        setTimeout(() => {
          // Create a temporary link with the same fragment, but without a click listener.
          const temporary_link = document.createElement('a');
          temporary_link.setAttribute('href', jump_link.getAttribute('href'));
          temporary_link.click();
        }, 1);
      }
    });
  });
})();

For example, the result of clicking on a jump link to a piece of content obscured by an accordion that lives within an inactive tab will be:

  1. Jump link is clicked
  2. The proper tab is activated
  3. Accordion component is activated
  4. A temporary copy of the jump link without the click handler is clicked

As a result the user is scrolled to the correct position with their motion preference respected, focus is in the correct position following native browser rules for focus-visible, and history is automatically managed.  Isn't that so much nicer than a spaghetti code approach from the 90's?