What it takes to scroll well

What it takes to scroll well

·

10 min read

This article, reposted from Padlet's blog, is their first-ever technical piece.

With books, we have the freedom to include as many pages as necessary to convey information to our readers. However, with screen devices, we are limited to a single frame to condense all that knowledge. Fortunately, Aza Raskin popularized the concept of scrolling through the invention of infinite scrolling, teaching us how to create and consume endless content on our compact screens.

As developers, how can we provide an enjoyable scrolling experience for our users when dealing with content that has a finite end?

The most basic attribute to enable scrolling

Scrolling is primarily enabled using a CSS property known as the overflow. We could declare a class as such to enable scrolling.

.scrollable {
  overflow: auto;
}

While we could have set overflow to scroll, setting overflow to auto makes it so that the scrollbar only shows on the element if the height of its content exceeds its content, rather than all the time. This enhances user experience.

However, what happens if we want scrolling to be controlled by elements outside of the target element?

This is a common use case for devices with small screen sizes, as it allows users to navigate to specific sections on a page.

For instance, consider a tall scrollable container element containing three section elements, with its height determined by its children. When the user clicks on button C, we expect the container to scroll to the top of section C.

Screenshot of a tall scrollable container element containing 3 section elements.

Notice that in the illustration above, section C is not directly at the top of the viewport. This is because a scrollable element cannot scroll beyond its height, which is either a fixed height or dynamically expanded by the height of its children.

Now, this is where things can get a little tricky.


Scrolling to a specific section

To add functionality to scroll to a specific section, we would need to know the y-coordinate of the section element (y) and scroll on the parent container element down by y pixels.

const x = 0 
document.querySelector('.scrollable').scroll(x, y)

But how do we know what is y?

Thankfully, the Web API provides us with many handy attributes we can access directly from HTML elements, including the getBoundingClientRect() method and offsetTop attribute to know exactly where the y-coordinate of the parent container element and child element is.

Using the properties above, we can write a simple function to scroll to the top of a child element inside the scrollable parent.

function scrollToTopOf(e: HTMLElement): void {
  const elementInChargeOfScrolling = document.querySelector('.scrollable')
  const containerElementY = containerElement.getBoundingClientRect().top
  const elementClientY = e.offsetTop
  const targetY = elementClientY - containerElementY
  elementInChargeOfScrolling.scroll(0, targetY)
}

This function is applicable for most common use cases, but what if we have multiple elements on the screen with .scrollable?

The function above would be problematic since document.querySelector() returns the first Element within the document that matches the specified selector, so it might not get the correct scrollable container that we want.

To resolve this, we wrote another function that relies on a convenient Web API method .closest().


Getting the right scrollable parent container

In our project, we used tailwind CSS for styling, so we check for the closest parent element that can have one of the various possible selectors that enables scrolling.

function getNearestScrollingElement(element: HTMLElement | null): HTMLElement | null {
  if (element == null) return null

  const scrollableStyleSelectors = [
    '.overflow-auto',
    '.overflow-y-auto',
    '.overflow-x-auto',
    "[style*='overflow:auto']",
    "[style*='overflow-y:auto']",
    "[style*='overflow-x:auto']",
  ]

  const closestElement = element?.closest(`*:is(${scrollableStyleSelectors.join(', ')})`)
  if (closestElement != null) {
    return closestElement as HTMLElement
  }

  return null
}

Integrating getNearestScrollingElement into the scrollToTopOf function, this is what we get.

function scrollToTopOf(e: HTMLElement, duration: number): void {
  const elementInChargeOfScrolling = getNearestScrollingElement(e)

  if (elementInChargeOfScrolling != null) {
    const containerElementY = elementInChargeOfScrolling.getBoundingClientRect().top
    const elementClientY = e.offsetTop
    const targetY = elementClientY - containerElementY
    elementInChargeOfScrolling.scroll(0, targetY)
  }
}

If we try out this function now, we will see that the container does work effectively as intended, but it doesn’t feel as good because the change happened too immediately. So let’s add some smooth scrolling.


Smooth Scrolling

While the Web API has proven to be quite useful for us, and even offers a scrolling function, the drawback of this method is that we are unable to control the speed or duration of the smooth scrolling.

We can pass an options object parameter to set the scrolling as smooth as such:

const options = {behavior: 'smooth'}
scrollTo(options)

However, it is a preset smooth scrolling behavior that we cannot configure.

In scrollable containers with an abundance of content, the container may become quite tall, and it could take a full second or even two for users to be smoothly scrolled from the top of the panel to the bottom.

jQuery is an often quoted, old and gold solution to address such problems where most functions that Web API has yet to make customizable. However, we wanted to reduce our dependency on it to comply with modern web app practices.

Hence we came up with our custom implementation of smooth scrolling, which uses a custom easeInOutCubic function and the requestAnimationFrame Web API method.

function easeInOutCubic(t: number, b: number, c: number, d: number): number {
  t /= d / 2
  if (t < 1) return (c / 2) * t * t * t + b
  t -= 2
  return (c / 2) * (t * t * t + 2) + b
}

/**
 * This function uses the requestAnimationFrame API and a easeInOutCubic function
 * to simulate smooth scrolling. This is helpful because not all browsers support smooth scrolling,
 * and the native Web API for smooth scrolling does not allow for scroll speed/duration configuration.
 *
 * @param {HTMLElement} el - element to scroll
 * @param {number} to - target y-coordinate
 * @param {number} duration - animation duration of scrolling
 */

function smoothScrollTo(el: HTMLElement, to: number, duration: number): void {
  const from = el.scrollTop
  const distance = to - from

  let start: number
  function step(timestamp: number): void {
    if (start === undefined) start = timestamp
    const elapsed = timestamp - start

    // Apply the ease-in-out animation
    el.scroll(0, easeInOutCubic(elapsed, from, distance, duration))
    if (elapsed < duration) {
      window.requestAnimationFrame(step)
    }
  }

  window.requestAnimationFrame(step)
}

With this, we achieved the functionality for perfect smooth scrolling to specific elements.

But are we done yet? Well, we can do more!


In the recent Padlet’s rollout of the newly furnished Settings Panel, as the user scrolls, they can see which section they are at as they scroll down with the nav bar at the top of the panel.

GIF of a user scrolling through the padlet settings

This is what I call interactive scrolling.


Interactive scrolling

To achieve interactive scrolling, we need to keep track of the sections that the user can see and then set the active section as they scroll.

A standard way to check if elements are visible is by using getBoundingClientRect() once again. Here’s a function to do that.

function isElementInViewport(element): boolean {
  const rect = element.getBoundingClientRect()
  return !(
    rect.top > (window.innerHeight || document.documentElement.clientHeight) ||
    rect.right < 0 ||
    rect.bottom < 0 ||
    rect.left > (window.innerWidth || document.documentElement.clientWidth)
  )
}

However, there are a few flaws with this approach.

  1. This assumes that the scrollable container spans the entire height of the user’s viewport. But the scrollable container can always be shorter than that.

  2. getBoundingClientRect() is an expensive function to use since it forces the web engine to constantly recalculate the layout. This would cause significant lag if we were to call this function on every scroll event to check the visibility of elements, as compared to only calling this function on navigating to a specific section.

After thorough research, we have found that using the IntersectionObserver is much better suited for tracking visible elements while scrolling, as it efficiently addresses the issue of potential lag caused by constant layout recalculations.


Using the IntersectionObserver, whenever we hit a certain visibility threshold, we will update the visibility of the section elements. The code below is Vue, but you can adapt it for any framework as well.

// pass in your list of section elements
// An easy way to differentiate your section elements could be to add an id or a dataset property to the element e.g.
// <div data-key='heading-section' />
const elements: Ref<HTMLElement[]> = [] 

// Create a list of booleans whose index matches the index of the element in the list above
const visibleElements = Ref<boolean[]> = [] 

function startObserving(): void {
  observer = new IntersectionObserver(
    (entries) => {
      entries.forEach((entry) => {
        // find the section element that is actually being observevd in this IntersectionObserver entry
        const index = elements.value.findIndex((el) => el.dataset.key === (entry.target as HTMLElement).dataset.key)
        // if we can find the section element, then we set its visibility according to whether it is intersecting with the viewport.
        if (index !== -1) {
          visibleElements.value[index] = entry.isIntersecting
          const selectedElementKeyIndex = visibleElements.value.indexOf(true)
          if (selectedElementKeyIndex !== -1) {
            selectedElementKey.value = elements.value[selectedElementKeyIndex].dataset.key
          }
        }
      })
    },
    {
        // we set multiple thresholds so that it will check frequently whether the visible elements are out of sight as the user scrolls. you can add more but it will probably be more computationally expensive.
      threshold: [0, 0.2, 0.4, 0.6, 0.8, 1],
    },
  )
  elements.value.forEach((item) => {
    observer?.observe(item)
  })
}

Note that multiple section elements can be concurrently visible.

Based on this visibility array, we can show the user which section they are in. There are different ways to define the active section which will be covered later, but for simplicity, Padlet implements the active section as the one that is located highest in the container and is visible to users.

For example, in the following screenshot, both the heading and appearance sections are completely visible, but we show the heading section as the active section.

Screenshot of padlet settings panel.

And that’s how we achieve interactive, smooth scrolling!

The next part is an optional food for thought for you on what the active section should be.


Where is the user really at?

In the words of my colleague Yong Sheng, sometimes one’s perception could also depend on one’s height. Little kids tend to notice toys that are placed at their height more than anything else in supermarkets, even though most of us probably wouldn’t even notice that those toys were there!

The same could apply to the concept of the active section.

In the context of a user’s usual scrolling behavior,

  • As the user is scrolling down, if the bottom-most section is visible, then that section should be the active section.

  • As the user is scrolling up, if the topmost section is visible, then that section should be the active section.

However, we cannot determine the exact location of a user's focus on larger screens. Sometimes, users may be relatively shorter or taller than their screens, leading to variations in their focal points.

In the case where the multiple elements in the scrollable element are entirely visible in the viewport, the user could be focusing on any of the sections.


Conclusion

In this article, we learned concepts on what enables scrolling and how we can enhance user experience with interactive scrolling. Everything comes to an end, and so does this article. Nonetheless, helping others enjoy their journey of navigating through life and content is a thoughtful and satisfying experience.

Below are some more gifts for you to further your knowledge of concepts on scrolling.


Other interesting resources

Readings

Interactive


That’s a wrap!

https://c.tenor.com/eoM1uCVuXtkAAAAM/yay-excited.gif

Thank you for reading, hope you enjoyed the article!

While the code is Vue, most of the concepts discussed are framework-agnostic, so you can easily apply what you learn here to your preferred stack.

If you find the article awesome, hit the reactions 🧡 and share it 🐦~

To stay updated whenever I post new content, follow me on Twitter.

To support me in content creation, you can buy a cup of tea for me

Did you find this article valuable?

Support Estee's Tech Blog by becoming a sponsor. Any amount is appreciated!