Create scrollspy in react

Learn how to create scrollspy in react.

Scrollspy is a navigation which shows the menu items as active if their corresponding section are in view area.

Scrollspy in react

There are two parts of scrollspy component.

  1. You can see in the image that as we scroll and when the sections are visible their respective menu link is becoming active.
  2. When clicked on these links we should also scroll to their corresponding section.

I am sure that now you have a got a good idea about what are we going to build, So lets start creating it.

But let me tell you this is a little complex component because we have to handle many different things.

To make our development little easy we are going to utilize few extra external packages.

  • prop-types: To validate the props.
  • classnames: This helps to use CSS classes as javascript objects, we just need to name our CSS file as filename.module.css.

Create the structure of the component by importing all the required packages at the top.

import React, { Component } from "react";
import PropTypes from "prop-types";
import cx from "classnames";
import styles from "./index.module.css";

class ScrollSpy extends Component {
   //other code will go here.
}

export default ScrollSpy;

Validating the props at the top helps to get a better idea about the layout and behavior of the component. Also set the defaults if required props are missing.

  static propTypes = {
    menus: PropTypes.arrayOf(PropTypes.string).isRequired,
    sections: PropTypes.arrayOf(PropTypes.element).isRequired,
    scrollSpeed: PropTypes.number.isRequired
  };

  static defaultProps = {
    scrollSpeed: 17
  };

menus contains the names of the menu items using which we generate the navbar inside our component.

sections contains all the elements which will are linked to the each menu items. It is ultra necessary to maintain the order otherwise the component won’t work properly.

We will not validate the items strength in both arrays as we expect the parent component to pass the proper data.

scrollSpeed determines the speed at which the window is scrolled to the section when menu items are clicked in the nav.

We are not using an external library instead we will be creating our own custom function for smooth scrolling.

Let us generate the layout before proceeding any further.

 render() {
    const { menus, sections } = this.props;

    //Menu
    const menusMapped = menus.map((e, i) => (
      <li
        key={e}
        onClick={() => this.handleScrollTo(i)}
        className={cx({
          [styles.active]: i === 0
        })}
      >
        <span>{e}</span>
      </li>
    ));

    //Sections
    const sectionsMapped = sections.map((e, i) => (
      <div key={i} className={cx(styles.section)}>
        {e}
      </div>
    ));

    return (
      <div>
        <nav className={styles.menu} ref={e => (this.navRef = e)}>
          <ul ref={e => (this.menuRef = e)}>{menusMapped}</ul>
        </nav>
        <div className={styles.container} ref={e => (this.sectionRef = e)}>
          {sectionsMapped}
        </div>
      </div>
    );
  }

As you can see we have simply generated the menu and the section and rendered it.

You will also notice that I have created many refs to the elements, this is because it is easy to access the elements with it and update them rather than updating the state.

Part 1: Mark menu items active as we scroll.

Now create the state to store different data inside the component.

Using sectionRef we will fetch all its children that is sections and get their offsetTop position and store them.

If navbar is sticky or fixed then the content is put below it, so we have extracted its height from the offset of elements by using navRef.

OffsetTop returns the distance of the element from the top of the screen in px.

The best place to do this is inside the componentDidMount lifecycle method which is invoked after the elements are rendered.

  state = {
    sectionOffsetPosition: []
  }; 
 
  componentDidMount() {
    //Get the list of sections
    const childrens = this.sectionRef.children;

    //Get menu height
    const navHeight = this.navRef.offsetHeight;

    //Store the top position of each section
    const sectionOffsetPosition = Array.prototype.map.call(
      childrens,
      e => e.offsetTop - navHeight
    );

    this.setState({ sectionOffsetPosition });

    //Listen to the scroll event
    window.addEventListener("scroll", this.handleScroll);
  }

As you can see we have also assigned a scroll listener in it because we want to get the number of pixel the page is scrolled.

This helps to check which section is in the view area by comparing the offsetTop and scroll position.

   handleScroll = () => {
    //Get the offset of all the sections
    const { sectionOffsetPosition } = this.state;

    //Get the list of menu links
    const menuChildrens = this.menuRef.children;

    //Current scroll position
    const scrollPosition = window.pageYOffset;

    //Check the scroll position with the section's position
    //Add 'active' class to the menu link whose section is visible
    sectionOffsetPosition.forEach((e, i) => {
      if (e <= scrollPosition) {
        //Remove active class from all the links
        Array.prototype.forEach.call(menuChildrens, f => {
          f.classList.remove(styles.active);
        });

        //Add active to class to visible section link
        menuChildrens[i].classList.add(styles.active);
      }
    });

    //If reached to the bottom of the page
    //Add the active class to last menu item
    if (window.innerHeight + window.scrollY >= document.body.offsetHeight) {
      Array.prototype.forEach.call(menuChildrens, f => {
        f.classList.remove(styles.active);
      });
      menuChildrens[menuChildrens.length - 1].classList.add(styles.active);
    }
  };

If the section is in the view area then it adds the active class to its corresponding menu link and remove the active class from others using the menuRef that we had created while rendering.

In the end we check if the user has scrolled to the bottom of the page then mark the last menu link as active because last section is now visible.

We have finished the first part of the component.

Part 2: Scroll to the section when user clicks on the menu link.

For this if you have noticed we had added a click event to the menu items and passed their index.

This index helps to determine the section to which we have to scroll.

handleScrollTo = i => {
    const { sectionOffsetPosition } = this.state;
       
    //Scroll to the selected div
    this.scrollTo(sectionOffsetPosition[i]);
  };

But as you may remember we have to create a custom function for smooth scrolling, so lets create that.

We do smooth scrolling by changing the scroll position at given interval or speed.

The only problem with this is the direction of scroll, if we are moving up then we remove the pixel else if we are moving bottom then we add pixels.

  scrollTowardsTop = pos => {
    //No of pixel to scroll
    const scrollStepInPx = (window.pageYOffset - pos) / 5;

    //If reached to the desired div
    //Stop scrolling
    if (window.pageYOffset === pos) {
      clearInterval(this.timer);
    }

    //If reached to the top of the page
    //Stop scrolling
    if (window.innerHeight + window.scrollY <= 10) {
      clearInterval(this.timer);
    }

    //Scroll step by step
    window.scroll(0, window.pageYOffset - scrollStepInPx);
  };

  scrollTowardsBottom = pos => {
    //No of pixel to scroll
    const scrollStepInPx = (pos - window.pageYOffset) / 5;

    //If reached to the desired div
    //Stop scrolling
    if (window.pageYOffset >= pos) {
      clearInterval(this.timer);
    }

    //If reached to the bottom of the page
    //Stop scrolling
    if (window.innerHeight + window.scrollY >= document.body.offsetHeight) {
      clearInterval(this.timer);
    }

    //Scroll step by step
    window.scroll(0, window.pageYOffset + scrollStepInPx + 5);
  };

  //Start scrolling to top
  scrollTo = pos => {
    //Clear existing timers
    clearInterval(this.timer);

    //Speed at which scroll to div
    const delayInMs = 16;

    //Scroll direction
    const scrollStep =
      window.pageYOffset > pos
        ? this.scrollTowardsTop
        : this.scrollTowardsBottom;

    //Start the scroll
    this.timer = setInterval(() => {
      scrollStep(pos);
    }, delayInMs);
  };

We determine whether we have to scroll up or down towards the requested section and then call the appropriate function with the interval.

While moving down, if we have reached to the section or at the bottom of the page then stop the interval.

Again same while moving up, if we have reached to the top or the section then clear timer.

Complete code of scrollspy in react.

import React, { Component } from "react";
import PropTypes from "prop-types";
import cx from "classnames";
import styles from "./index.module.css";

class ScrollSpy extends Component {
  state = {
    sectionOffsetPosition: []
  };

  static propTypes = {
    menus: PropTypes.arrayOf(PropTypes.string).isRequired,
    sections: PropTypes.arrayOf(PropTypes.element).isRequired,
    scrollSpeed: PropTypes.number.isRequired
  };

  static defaultProps = {
    scrollSpeed: 17
  };

  componentDidMount() {
    //Get the list of sections
    const childrens = this.sectionRef.children;

    //Get menu height
    const navHeight = this.navRef.offsetHeight;

    //Store the top position of each section
    const sectionOffsetPosition = Array.prototype.map.call(
      childrens,
      e => e.offsetTop - navHeight
    );

    this.setState({ sectionOffsetPosition });

    //Listen to the scroll event
    window.addEventListener("scroll", this.handleScroll);
  }

  componentWillUnmount() {
    //Remove the scroll listener
    window.removeEventListener("scroll", () => {});
  }

  handleScroll = () => {
    //Get the offset of all the sections
    const { sectionOffsetPosition } = this.state;

    //Get the list of menu links
    const menuChildrens = this.menuRef.children;

    //Current scroll position
    const scrollPosition = window.pageYOffset;

    //Check the scroll position with the section's position
    //Add 'active' class to the menu link whose section is visible
    sectionOffsetPosition.forEach((e, i) => {
      if (e <= scrollPosition) {
        //Remove active class from all the links
        Array.prototype.forEach.call(menuChildrens, f => {
          f.classList.remove(styles.active);
        });

        //Add active to class to visible section link
        menuChildrens[i].classList.add(styles.active);
      }
    });

    //If reached to the bottom of the page
    //Add the active class to last menu item
    if (window.innerHeight + window.scrollY >= document.body.offsetHeight) {
      Array.prototype.forEach.call(menuChildrens, f => {
        f.classList.remove(styles.active);
      });
      menuChildrens[menuChildrens.length - 1].classList.add(styles.active);
    }
  };

  scrollTowardsTop = pos => {
    //No of pixel to scroll
    const scrollStepInPx = (window.pageYOffset - pos) / 5;

    //If reached to the desired div
    //Stop scrolling
    if (window.pageYOffset === pos) {
      clearInterval(this.timer);
    }

    //If reached to the top of the page
    //Stop scrolling
    if (window.innerHeight + window.scrollY <= 10) {
      clearInterval(this.timer);
    }

    //Scroll step by step
    window.scroll(0, window.pageYOffset - scrollStepInPx);
  };

  scrollTowardsBottom = pos => {
    //No of pixel to scroll
    const scrollStepInPx = (pos - window.pageYOffset) / 5;

    //If reached to the desired div
    //Stop scrolling
    if (window.pageYOffset >= pos) {
      clearInterval(this.timer);
    }

    //If reached to the bottom of the page
    //Stop scrolling
    if (window.innerHeight + window.scrollY >= document.body.offsetHeight) {
      clearInterval(this.timer);
    }

    //Scroll step by step
    window.scroll(0, window.pageYOffset + scrollStepInPx + 5);
  };

  //Start scrolling to top
  scrollTo = pos => {
    //Clear existing timers
    clearInterval(this.timer);

    //Speed at which scroll to div
    const delayInMs = 16;

    //Scroll direction
    const scrollStep =
      window.pageYOffset > pos
        ? this.scrollTowardsTop
        : this.scrollTowardsBottom;

    //Start the scroll
    this.timer = setInterval(() => {
      scrollStep(pos);
    }, delayInMs);
  };

  handleScrollTo = i => {
    const { sectionOffsetPosition } = this.state;

    //Scroll to the selected div
    this.scrollTo(sectionOffsetPosition[i]);
  };

  render() {
    const { menus, sections } = this.props;

    //Menu
    const menusMapped = menus.map((e, i) => (
      <li
        key={e}
        onClick={() => this.handleScrollTo(i)}
        className={cx({
          [styles.active]: i === 0
        })}
      >
        <span>{e}</span>
      </li>
    ));

    //Sections
    const sectionsMapped = sections.map((e, i) => (
      <div key={i} className={cx(styles.section)}>
        {e}
      </div>
    ));

    return (
      <div>
        <nav className={styles.menu} ref={e => (this.navRef = e)}>
          <ul ref={e => (this.menuRef = e)}>{menusMapped}</ul>
        </nav>
        <div className={styles.container} ref={e => (this.sectionRef = e)}>
          {sectionsMapped}
        </div>
      </div>
    );
  }
}

export default ScrollSpy;

Complete style code of scrollspy component in react.

//index.module.css
.container {
  margin-top: 75px;
}

.menu {
  display: flex;
  justify-content: flex-end;
  align-items: center;
  padding: 0 20px;
  position: fixed;
  width: 100%;
  top: 0;
  height: 70px;
  background-color: rgba(0, 0, 255, 0.5);
}

.menu > ul {
  padding: 0;
  list-style-type: none;
  display: inline-flex;
  justify-content: space-between;
  align-items: center;
  margin: 0;
}

.menu > ul > li {
  margin: 0 5px;
}

.menu > ul > li > span {
  display: inline-block;
  padding: 10px 20px;
  background-color: red;
  transition: all 0.2s ease;
  cursor: pointer;
}

li.active > span {
  background-color: beige !important;
}

.section {
  display: block;
  font-size: 1.5em;
  height: 65vh;
}

.red {
  background-color: red;
}

.blue {
  background-color: blue;
}

.yellow {
  background-color: yellow;
}

.green {
  background-color: green;
}

Input

import React from "react";
import ReactDOM from "react-dom";
import "./index.css";
import ScrollSpy from "./Components/ScrollSpy";
import * as serviceWorker from "./serviceWorker";

const divs = ["Section 1", "Section 2", "Section 3", "Section 4"].map(e => (
  <div key={e}>
    <h1>{e}</h1>
    <p>
      Lorem Ipsum is simply dummy text of the printing and typesetting industry.
      Lorem Ipsum has been the industry's standard dummy text ever since the
      1500s, when an unknown printer took a galley of type and scrambled it to
      make a type specimen book. It has survived not only five centuries, but
      also the leap into electronic typesetting, remaining essentially
      unchanged. It was popularised in the 1960s with the release of Letraset
      sheets containing Lorem Ipsum passages, and more recently with desktop
      publishing software like Aldus PageMaker including versions of Lorem Ipsum
    </p>
  </div>
));

ReactDOM.render(
  <div className="abc">
    <ScrollSpy
      menus={["Home", "Portfolio", "About", "Contact"]}
      sections={divs}
    />
  </div>,
  document.getElementById("root")
);

// If you want your app to work offline and load faster, you can change
// unregister() to register() below. Note this comes with some pitfalls.
// Learn more about service workers: https://bit.ly/CRA-PWA
serviceWorker.unregister();