DEV Community

Cover image for How to Create a Sticky On Scroll Effect with JavaScript
Cruip
Cruip

Posted on • Originally published at cruip.com

How to Create a Sticky On Scroll Effect with JavaScript

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>
Enter fullscreen mode Exit fullscreen mode

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);
  });
Enter fullscreen mode Exit fullscreen mode

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>
      ...
Enter fullscreen mode Exit fullscreen mode

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:

  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);
  });
Enter fullscreen mode Exit fullscreen mode

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 explained.

  • 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);
Enter fullscreen mode Exit fullscreen mode

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:

  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');
    }
  });
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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, or an Infinite Horizontal Scroll Animation with Tailwind CSS.

Top comments (0)