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.
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.
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.
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.
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.
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
You Might Not Need jQuery - code snippets of using modern HTML vs Web API jQuery to achieve certain use cases
Infinite scroll with Next.js, Framer Motion, and Supabase - a step-by-step demo on infinite scroll and animation. Hint: you can actually improve the implementation here using what you learned in this article!
Interactive
Intersection Observer Playground - see how the intersection observer work by configuring the controls yourself
Make your website stand out with a custom scrollbar 🌟 by the author of the article you’re reading now - learn the basics of how a scrollbar is styled
That’s a wrap!
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 ✨