How to Create a Sticky On Scroll Effect with JavaScript
Tags: webdev, html, tutorial, tailwindcss
Published: 2023-10-10 07:48:55

####**[Live Demo](https://cruip-tutorials.vercel.app/sticky-scrolling/) / [Download](https://github.com/cruip/cruip-tutorials/blob/main/sticky-scrolling/)**

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](https://www.mercu.com/) 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.

```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:

```javascript
  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:

```html
  <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 to `0`.
*   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` equals `4`).
*   When the container intersects the viewport’s top edge, values fall within the defined range.

Let’s put this theory into practice:

```javascript
  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 a `onScroll` function that will be called every time scrolling takes place.
*   The `onScroll` function, in turn, calls the `handleSections` method. The rationale behind creating a separate method is to avoid code duplication, as `handleSections` 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 explaine](#conditions)d.
    
*   To complete the picture, the `remapValue` method comes into play, enabling us to remap the `viewportTop` value within the predefined value range (from `0` to `4`, 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 of `scrollValue` 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](https://www.w3schools.com/css/css3_variables.asp):

```ts
  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](https://tailwindcss.com/docs/adding-custom-styles#arbitrary-variants):

```html
  <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 included `transition-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](https://cruip.com/creating-a-css-only-card-slider-with-tailwind-css/?swcfpc=1), or an [Infinite Horizontal Scroll Animation with Tailwind CSS](https://cruip.com/create-an-infinite-horizontal-scroll-animation-with-tailwind-css/?swcfpc=1).