How to Create a Sticky On Scroll Effect with JavaScript
Cruip
Posted on October 10, 2023
Live Demo / Download
A sticky scroll effect is a quite popular animation used to show related content that overlaps without having to scroll down the page. In simpler words, it lets the user “access” multiple pieces of information while staying in the same position on a page.
This effect comes with many pros, as it tends to lighten up a page with lots of content; however, it’s not recommended when the information is dense, as the user is forced to view it all before moving to another part of a page.
For this tutorial, we took inspiration from the beautiful landing page of Mercu and tried to make it look like it using our original design style and expertise.
Creating the HTML structure with sections
For this example, we used Tailwind CSS. As the focus here is on the JavaScript aspect, I won’t go into explaining the CSS part. You can simply use this ready-to-use HTML.
<div class="max-w-md mx-auto lg:max-w-none">
<div class="lg:sticky lg:top-0 lg:min-h-screen space-y-16 lg:space-y-0">
<!-- Section #1 -->
<section class="lg:absolute lg:inset-0">
<div class="flex flex-col lg:min-h-full lg:flex-row space-y-4 space-y-reverse lg:space-y-0 lg:space-x-20">
<div class="flex-1 flex items-center order-1 lg:order-none">
<div class="space-y-3">
<div class="relative inline-flex text-indigo-500 font-semibold">
Integrated Knowledge
<svg class="fill-indigo-300 absolute top-full w-full" xmlns="http://www.w3.org/2000/svg" width="166" height="4">
<path d="M98.865 1.961c-8.893.024-17.475.085-25.716.182-2.812.019-5.023.083-7.622.116l-6.554.067a2910.9 2910.9 0 0 0-25.989.38c-4.04.067-7.709.167-11.292.27l-1.34.038c-2.587.073-4.924.168-7.762.22-2.838.051-6.054.079-9.363.095-1.994.007-2.91-.08-3.106-.225l-.028-.028c-.325-.253.203-.463 1.559-.62l.618-.059c.206-.02.42-.038.665-.054l1.502-.089 3.257-.17 2.677-.132c.902-.043 1.814-.085 2.744-.126l1.408-.06c4.688-.205 10.095-.353 16.167-.444C37.413 1.22 42.753.98 49.12.824l1.614-.037C54.041.707 57.588.647 61.27.6l1.586-.02c4.25-.051 8.53-.1 12.872-.14C80.266.4 84.912.373 89.667.354l2.866-.01c8.639-.034 17.996 0 27.322.03 6.413.006 13.168.046 20.237.12l2.368.027c1.733.014 3.653.05 5.712.105l2.068.064c5.89.191 9.025.377 11.823.64l.924.09c.802.078 1.541.156 2.21.233 1.892.233.29.343-3.235.364l-3.057.02c-.446.003-.89.008-1.33.014a305.77 305.77 0 0 1-4.33-.004c-2.917-.005-5.864-.018-8.783-.019l-4.982.003a447.91 447.91 0 0 1-3.932-.02l-4.644-.023-4.647-.014c-9.167-.026-18.341-.028-26.923.03l-.469-.043Z" />
</svg>
</div>
<h2 class="text-4xl text-slate-900 font-extrabold">Support your users with popular topics</h2>
<p class="text-lg text-slate-500">Statistics show that people browsing your webpage who receive live assistance with a chat widget are more likely to make a purchase.</p>
</div>
</div>
<div class="flex-1 flex items-center">
<img width="512" height="480" src="./illustration-01.png" alt="Illustration 01" />
</div>
</div>
</section>
</div>
</div>
The code snippet includes a container and a single sample section. We’ll be adding more sections as we proceed with the JavaScript.
The section layout consists of some text on the left side and an image on the right, designed to be responsive and stack on smaller screens. Note the minimum container height, equal to the viewport’s height, and the use of absolute positioning for sections. This approach allows us to stack sections effortlessly, with our main focus on handling their visibility and transitions.
Important: The transition effect will be active only on screens larger than 1024px. This decision ensures that the content remains accessible on smaller screens, where space might be limited.
Getting started with JavaScript: Setting container height
Our goal is to make sections overlap as we scroll down the page. To achieve this, we must track when scrolling reaches the point where the next section should become visible. To do this, we need to calculate the hypothetical total height of the container if all sections were stacked one after the other.
JavaScript can help us calculate this value. Assuming each section has a height equal to the viewport’s height (i.e., lg:h-screen
), we’ll assign a minimum height of 100vh
multiplied by the number of sections plus 1, to the container. Adding one unit ensures that the last section remains sticky on the screen, similar to the others, instead of disappearing upon scrolling into view.
Let’s kickstart our JavaScript setup:
class StickySections {
constructor(containerElement) {
this.container = {
el: containerElement,
}
this.sections = Array.from(this.container.el.querySelectorAll('section'));
this.initContainer = this.initContainer.bind(this);
this.init();
}
initContainer() {
this.container.el.style.setProperty('--stick-items', `${this.sections.length + 1}00vh`);
}
init() {
this.initContainer();
}
}
// Init StickySections
const sectionsContainer = document.querySelectorAll('[data-sticky-sections]');
sectionsContainer.forEach((section) => {
new StickySections(section);
});
Now, we identify the container element using the data-sticky-sections
attribute. Tailwind CSS arbitrary variants allow us to dynamically set the container’s height:
<div class="max-w-md mx-auto lg:max-w-none lg:min-h-[var(--stick-items)]" data-sticky-sections>
...
For instance, with 3 sections, the container’s height would be set to 400vh
.
Determining scroll points between sections
Next, we need to pinpoint the scroll positions for switching between sections. These points depend on the container’s position relative to the viewport. Instead of lengthy explanations, let’s implement it directly.
We’ll create a scrollValue
variable, initially set to 0
. Values for this variable follow this logic:
- If the container’s top edge is below the viewport’s top edge,
scrollValue
is set to0
. - If the container’s bottom edge is above the viewport’s top edge,
scrollValue
equals the number of sections plus 1 (e.g., with 3 sections,scrollValue
equals4
). - When the container intersects the viewport’s top edge, values fall within the defined range.
Let’s put this theory into practice:
class StickySections {
constructor(containerElement) {
this.container = {
el: containerElement,
height: 0,
top: 0,
bottom: 0,
}
this.sections = Array.from(this.container.el.querySelectorAll('section'));
this.viewportTop = 0;
this.scrollValue = 0; // Scroll value of the sticky container
this.onScroll = this.onScroll.bind(this);
this.initContainer = this.initContainer.bind(this);
this.handleSections = this.handleSections.bind(this);
this.remapValue = this.remapValue.bind(this);
this.init();
}
onScroll() {
this.handleSections();
}
initContainer() {
this.container.el.style.setProperty('--stick-items', `${this.sections.length + 1}00vh`);
}
handleSections() {
this.viewportTop = window.scrollY;
this.container.height = this.container.el.clientHeight;
this.container.top = this.container.el.offsetTop;
this.container.bottom = this.container.top + this.container.height;
if (this.container.bottom <= this.viewportTop) {
// The bottom edge of the stickContainer is above the viewport
this.scrollValue = this.sections.length + 1;
} else if (this.container.top >= this.viewportTop) {
// The top edge of the stickContainer is below the viewport
this.scrollValue = 0;
} else {
// The stickContainer intersects with the viewport
this.scrollValue = this.remapValue(this.viewportTop, this.container.top, this.container.bottom, 0, this.sections.length + 1);
}
}
// This function remaps a value from one range to another range
remapValue(value, start1, end1, start2, end2) {
const remapped = (value - start1) * (end2 - start2) / (end1 - start1) + start2;
return remapped > 0 ? remapped : 0;
}
init() {
this.initContainer();
this.handleSections();
window.addEventListener('scroll', this.onScroll);
}
}
// Init StickySections
const sectionsContainer = document.querySelectorAll('[data-sticky-sections]');
sectionsContainer.forEach((section) => {
new StickySections(section);
});
I’ve introduced a substantial amount of code, so let’s break it down step by step:
- I’ve registered a
scroll
event on the window and created aonScroll
function that will be called every time scrolling takes place. - The
onScroll
function, in turn, calls thehandleSections
method. The rationale behind creating a separate method is to avoid code duplication, ashandleSections
also needs to be triggered during initialization. -
I introduced an additional set of variables essential for calculating the values of
scrollValue
:-
viewportTop
represents the scroll value in pixels. -
container.height
corresponds to the container’s height in pixels. -
container.top
indicates the pixel distance between the top edge of the container and the top edge of the document. -
container.bottom
indicates the pixel distance between the bottom edge of the container and the top edge of the document.
These variables will allow us to determine the value of
scrollValue
, as previously explained. -
To complete the picture, the
remapValue
method comes into play, enabling us to remap theviewportTop
value within the predefined value range (from0
to4
, assuming 3 sections).
The scrollValue
variable dynamically updates as you scroll, allowing us to determine the section index to display.
Determining the displayed section’s index
This step may appear complicated but is straightforward. We create an activeIndex
variable, initially set to 0
(indicating the first section should display).
Inside the handleSections
method, we update activeIndex
with this line:
this.activeIndex = Math.floor(this.scrollValue) >= this.sections.length ? this.sections.length - 1 : Math.floor(this.scrollValue);
Copy
It’s a simple ternary operator answering the question: “Is scrollValue greater than or equal to the number of sections?”. Or, to simplify, “Is the last section intersecting or has passed upwards beyond the viewport’s top edge?”. There are two possible answers:
Otherwise, activeIndex equals scrollValue rounded down.
- Yes, and
activeIndex
will be equal to the last index of the sections. - No, and
activeIndex
will be equal to the value ofscrollValue
rounded down.
Managing section visibility and entry/exit effects
We’re nearing the end of this tutorial! Now that we’ve identified the section index to display, completing the effect is straightforward. Multiple approaches exist; in this example, we use a forEach
loop and CSS variables:
this.sections.forEach((section, i) => {
if (i === this.activeIndex) {
section.style.setProperty('--stick-visibility', '1');
section.style.setProperty('--stick-scale', '1');
} else {
section.style.setProperty('--stick-visibility', '0');
section.style.setProperty('--stick-scale', '.8');
}
});
In essence, if the current section should display, we set visibility and scale to 1
. Otherwise, we set visibility to 0
and scale to 0.8
.
Now, let’s apply these CSS variables in HTML, utilizing Tailwind CSS arbitrary variants:
<section class="lg:absolute lg:inset-0 lg:z-[var(--stick-visibility)]">
<div class="flex flex-col lg:h-full lg:flex-row space-y-4 space-y-reverse lg:space-y-0 lg:space-x-20">
<div class="flex-1 flex items-center lg:opacity-[var(--stick-visibility)] transition-opacity duration-300 order-1 lg:order-none">
<div class="space-y-3">
<div class="relative inline-flex text-indigo-500 font-semibold">
Integrated Knowledge
<svg class="fill-indigo-300 absolute top-full w-full" xmlns="http://www.w3.org/2000/svg" width="166" height="4">
<path d="M98.865 1.961c-8.893.024-17.475.085-25.716.182-2.812.019-5.023.083-7.622.116l-6.554.067a2910.9 2910.9 0 0 0-25.989.38c-4.04.067-7.709.167-11.292.27l-1.34.038c-2.587.073-4.924.168-7.762.22-2.838.051-6.054.079-9.363.095-1.994.007-2.91-.08-3.106-.225l-.028-.028c-.325-.253.203-.463 1.559-.62l.618-.059c.206-.02.42-.038.665-.054l1.502-.089 3.257-.17 2.677-.132c.902-.043 1.814-.085 2.744-.126l1.408-.06c4.688-.205 10.095-.353 16.167-.444C37.413 1.22 42.753.98 49.12.824l1.614-.037C54.041.707 57.588.647 61.27.6l1.586-.02c4.25-.051 8.53-.1 12.872-.14C80.266.4 84.912.373 89.667.354l2.866-.01c8.639-.034 17.996 0 27.322.03 6.413.006 13.168.046 20.237.12l2.368.027c1.733.014 3.653.05 5.712.105l2.068.064c5.89.191 9.025.377 11.823.64l.924.09c.802.078 1.541.156 2.21.233 1.892.233.29.343-3.235.364l-3.057.02c-.446.003-.89.008-1.33.014a305.77 305.77 0 0 1-4.33-.004c-2.917-.005-5.864-.018-8.783-.019l-4.982.003a447.91 447.91 0 0 1-3.932-.02l-4.644-.023-4.647-.014c-9.167-.026-18.341-.028-26.923.03l-.469-.043Z" />
</svg>
</div>
<h2 class="text-4xl text-slate-900 font-extrabold">Support your users with popular topics</h2>
<p class="text-lg text-slate-500">Statistics show that people browsing your webpage who receive live assistance with a chat widget are more likely to make a purchase.</p>
</div>
</div>
<div class="flex-1 flex items-center lg:scale-[var(--stick-scale)] lg:opacity-[var(--stick-visibility)] transition duration-300">
<img width="512" height="480" src="./illustration-01.png" alt="Illustration 01" />
</div>
</div>
</section>
As seen above, we’ve added the following classes:
-
lg:z-[var(--stick-visibility)]
increases the z-index for the displayed section. -
lg:opacity-[var(--stick-visibility)]
applies to the left section with text, ensuring an opacity of 1 for the displayed section and 0 for others. We’ve also includedtransition-opacity duration-300
for a smooth opacity transition. -
lg:scale-[var(--stick-scale)] lg:opacity-[var(--stick-visibility)]
is added to the right section of each element. These classes follow the same logic as the previous point but also include scaling animation for added flair.
With this, we’ve reached the conclusion of our tutorial. You can download the complete code by clicking the Download button at the top of the page. While there’s room for optimization, this code is functional for real projects. Feel free to adapt it to your requirements or experiment with additional CSS variables to create various effects. The power to create is in your hands!
Conclusions
We hope you found this tutorial helpful for making a smooth sticky scroll effect for your next project.
If you like these modern web effects, we suggest you check out how to create a CSS-only Card Slider with Tailwind CSS, or an Infinite Horizontal Scroll Animation with Tailwind CSS.
Posted on October 10, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.