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
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 div
s 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:
useRef
to solve this issue,Edit
Further to comments below, this is a clarification of the second question above
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:
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:
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>