Search code examples
javascriptcssreactjsscrollbar

React - creating a custom scrollbar with headers which are proportional to individual sections


I am trying to create a proportional fixed header scrollbar which sticks to the right hand side of the page

An example image is attached.

Image

1

The turquoise scrollbar should have a div inside it which represents each box (left with black border) that exists. There may be any number of these "sections" - I have filled them with lorem ipsum text. The height of the divs in the right scrollbar should reflect the respective height of the section it represents.

On clicking a div, it should scroll the respective section into view.

My code is as follows:

JSX:

const sections = [
  'Lorem ...',
  'Lorem ...',
  'Lorem ...',
  'Lorem ...',
  'Lorem ...',
  'Lorem ...',
  'Lorem ...',
  'Lorem ...',
  'Lorem ...',
  'Lorem ...',
]

function SectionComponent(props) {
  return (<div className="section">{props.section}</div>)
}

export default function section() {
  return (
    <>
      {
        sections.map((section, index) => {
          return <SectionComponent key={index} section={section} />
        })
      }

      <div id="scrollbar">
        {
          sections.map((section, index) => {
            return (
              <div className="scroll-element" key={index}
                onClick={() => {
                  const ele = document.querySelectorAll('.section')[index]
                  ele.scrollIntoView({ behavior: 'smooth', block: 'start' })
                }}
              >
                {index}
              </div>
            )
          })
        }
      </div>
    </>
  )
}

CSS:

div.section {
  border: 2px solid black;
  width: 80%;
  margin: 1em auto;
}

div#scrollbar {
  position: fixed;
  width: 20px;
  height: 100%;
  top: 0; 
  right: 0;
  background-color: aquamarine;
  display: flex;
  flex-direction: column;
  flex-wrap: nowrap;
  align-items: center;
  justify-content: space-around;
  
  div.scroll-element {
    padding-left: 5px;
    padding-right: 5px;
    height: 20px;
    transform: rotate(-90deg);
    overflow: hidden;
    text-overflow: ellipsis;
  }
}

I have been able to come up with a way to link each scrollbar div to its respective section. I know that using refs is likely the best way, but do not know how to do it with an unknown number of sections.

My questions are:

  • how would someone use useRef to solve this issue,
  • how do you get the divs on the right to be proportional to the height of its respective section

Edit

Further to comments below, this is a clarification of the second question above

Image

The image shows multiple sections. The div on the right scrollbar should be proportional to the size of the section box.

E.g. if there are four sections in total, with their heights being:

  • 1: 100px
  • 2: 200px
  • 3: 300px
  • 4: 400px

Then the corresponding divs on the right scrollbar should have their height reflected in this (likely as a percentage).

Total of all the sections = 100 + 200 + 300 + 400 = 1000px

Scrollbar divs:

  • 1: 100/1000 => 10% height
  • 2: 200/1000 => 20% height
  • 3: 300/1000 => 30% height
  • 4: 400/1000 => 40% height

Image to illustrate


Solution

  • Update 2

    If some items could possibly be removed from the list of refs, perhaps try add a filter for null refs when setting new sizes to see if it avoids any errors in the use case.

    useEffect(() => {
      setSizes((prev) => {
        // 👇 Added a filter for null ref item
        const newSizes = sectionsRef.current
          .filter((item) => !!item)
          .map((item) => {
            const { height } = item.getBoundingClientRect();
            return height;
          });
        const total = newSizes.reduce((acc, cur) => acc + cur, 0);
        const newProportions = newSizes.map((size) =>
          Math.round((size / total) * 100)
        );
        return newProportions;
      });
    }, []);
    

    Update

    Regarding the Edit part, a basic implement could be calling getBoundingClientRect() on the items to get a set of height and assign a calculated percentage to each element in the right bar.

    Live demo with basic implement: stackblitz

    Noted that as a basic solution, this currently runs on elements mounting and does not consider events that may impact the heights, such as window resizes. Further detection of such changes could be added, but not without some throttling also likely to be needed, so it could handle situations when changes happen too often.

    Example:

    First add a state for sizes and set it when the elements (already referenced in the original answer) mount, with help of useEffect. When setting the values, perhaps use getBoundingClientRect to calculate a percentage for each section:

    More about getBoundingClientRect

    const [sizes, setSizes] = useState([]);
    
    useEffect(() => {
      setSizes((prev) => {
        const newSizes = sectionsRef.current.map((item) => {
          const { height } = item.getBoundingClientRect();
          return height;
        });
        const total = newSizes.reduce((acc, cur) => acc + cur, 0);
        const newProportions = newSizes.map((size) =>
          Math.round((size / total) * 100)
        );
        return newProportions;
      });
    }, []);
    

    Secondly, assign the calculated percentage value to the elements:

    <div id="scrollbar">
      {sections.map((section, index) => {
        return (
          <div
            className="scroll-element"
            key={index}
            style={{ flexBasis: sizes[index] ? `${sizes[index]}%` : "auto" }}
            onClick={() => handleScroll(index)}
          >
            {index}
          </div>
        );
      })}
    </div>
    

    Original

    Not sure if I understand the goal of the styles, so this answer will focus on this part:

    how would someone use useRef to solve this issue

    As a possible solution, perhaps try use an array ref to select the SectionComponent instead of querySelectorAll.

    A basic live demo for the example below: stackblitz

    First configure SectionComponent to forward ref to its output element. Here props.children is used as it is a common pattern to add content, but it is totally optional.

    More about: forwardRef

    import React from 'react';
    
    const SectionComponent = React.forwardRef((props, ref) => {
      return (
        <div className="section" ref={ref}>
          {props.children}
        </div>
      );
    });
    

    Secondly, define the ref as an array and assign to the elements in map():

    More about: ref as a list

    import React, { useRef } from 'react';
    
    const sectionsRef = useRef([]);
    
    <div className="sections">
      {sections.map((section, index) => {
        return (
          <SectionComponent
            key={index}
            ref={(ele) => (sectionsRef.current[index] = ele)}
          >
            {`Section ${index}: ${section}`}
          </SectionComponent>
        );
      })}
    </div>
    

    Then to achieve the functionality with scrollIntoView, perhaps define a handler for scrolling and assign it to the elements in scrollbar.

    const handleScroll = (index) => {
      if (!sectionsRef.current[index]) return;
      sectionsRef.current[index].scrollIntoView({ behavior: "smooth" });
    };
    
    <div id="scrollbar">
      {sections.map((section, index) => {
        return (
          <div
            className="scroll-element"
            key={index}
            onClick={() => handleScroll(index)}
          >
            {index}
          </div>
        );
      })}
    </div>