Creating Custom Scrollbars with React
This article was originally published on the This Dot blog.
Styling scrollbars is no simple task. The CSS available to style them is different across browsers, and not very featureful. If you want to make something extremely customized, you can’t rely on the native scrollbars—you have to build your own out of DOM elements. This blog post shows you how to do just that using React and TypeScript. If you want to skip straight to the final functional demo, go here.
Structure of a Scrollbar
A scroll bar has two main components: a thumb (this piece you click and drag to scroll) and a track (the space within which the thumb moves). The size of the thumb gives usually gives a hint to how large the content you can currently see is in relation to the total size of the content. That is to say, the smaller the thumb, the more content there is outside of the current view. Thumb position on the track tells you how much off-screen content there is in each scroll direction. Scrollbars sometimes also have buttons on the ends that you can click to scroll a set amount in a certain direction, though these aren’t seen as often as they used to be. We’ll be recreating both of these user interface elements with div
elements.
Hide Native Scrollbars
Before we create our custom scrollbars, we’ll need to hide the native browser scrollbars to prevent interference. This is easily accomplished with a bit of CSS. We’re also including overflow: auto
, because we want to make sure that out content is still scrollable even if we can’t see the native scrollbars.
.custom-scrollbars__content {
overflow: auto;
scrollbar-width: none;
-ms-overflow-style: none;
}
.custom-scrollbars__content::-webkit-scrollbar {
display: none;
}
Custom Scrollbar Functionality
Laying out Scrollbar Elements
The content we want to scroll and the scrollbar itself will go inside a wrapper div
. That div
will have two children: another div
containing the content, and a div
containing our scrollbar.
The scrollbar div
will contain a button
to scroll up, the elements of the thumb and track inside a wrapper div
, and another button
to scroll down. In the example below, they’ve been given classes (remember, we’re using React, so we’re using className
) to help identify them.
<div className="custom-scrollbars__container">
<div className="custom-scrollbars__content">{children}</div>
<div className="custom-scrollbars__scrollbar">
<button className="custom-scrollbars__button">⇑</button>
<div className="custom-scrollbars__track-and-thumb">
<div className="custom-scrollbars__track"></div>
<div className="custom-scrollbars__thumb"></div>
</div>
<button className="custom-scrollbars__button">⇓</button>
</div>
</div>
The track and thumb elements are next to each other and will both be positioned absolutely with CSS. The top
style of the thumb will be modified with JavaScript. It’s better to have these two side-by-side and positioned absolutely, rather than have the thumb as a child of the track, because it prevents any ambiguity about whether you’ve clicked one or the other. If you click and drag the thumb, but drag so fast that your cursor is over the track when you realease it, it’s possible, when nesting them, to accidentally trigger a click event on the track. That would could weird scrolling bugs. Keeping them as siblings prevents that.
.custom-scrollbars__track-and-thumb {
display: block;
height: 100%; /* must have some height */
position: relative;
width: 16px; /* must have some width */
}
/* The track is meant to fill the space it's given, so top and bottom are set to 0. */
.custom-scrollbars__track {
bottom: 0;
cursor: pointer;
position: absolute;
top: 0;
width: 16px; /* must have some width */
}
/* No top or bottom set for the thumb. That will be controlled by JavaScript. */
.custom-scrollbars__thumb {
position: absolute;
width: 16px; /* must have some width */
}
Sizing the Thumb
Your content may change size from initial render, due to data fetch requests completing or for other reasons. The size of the thumb needs to be able to adjust to match whatever the current content’s size is.
Because the content resizing won’t necessarily result in the window resizing, we’ll use a ResizeObserver
to what for changes in just the content size.
We store references to the content element, the track, the thumb, and the resize observer with useRef
. Our handleResize()
function computes the ratio of visible content to total scrollable content (clientHeight / scrollHeight
) of the content container, then multiplies it by the track’s height to get a height for the thumb. We set a minimum of 20 pixels for the heigh in case the content is so long that the thumb would otherwise be too small to click. The thumb’s height is finally stored in React state.
A useEffect
sets an initial height, then creates a ResizeObserver
to watch the size of the content, and trigger the handleResize()
function again if it occurs.
import React, { useState, useEffect, useRef } from 'react';
const Scrollbar = ({
children,
className,
...props
}: React.ComponentPropsWithoutRef<'div'>) => {
const contentRef = useRef<HTMLDivElement>(null);
const scrollTrackRef = useRef<HTMLDivElement>(null);
const scrollThumbRef = useRef<HTMLDivElement>(null);
const observer = useRef<ResizeObserver | null>(null);
const [thumbHeight, setThumbHeight] = useState(20);
function handleResize(ref: HTMLDivElement, trackSize: number) {
const { clientHeight, scrollHeight } = ref;
setThumbHeight(Math.max((clientHeight / scrollHeight) * trackSize, 20));
}
// If the content and the scrollbar track exist, use a ResizeObserver to adjust height of thumb and listen for scroll event to move the thumb
useEffect(() => {
if (contentRef.current && scrollTrackRef.current) {
const ref = contentRef.current;
const { clientHeight: trackSize } = scrollTrackRef.current;
observer.current = new ResizeObserver(() => {
handleResize(ref, trackSize);
});
observer.current.observe(ref);
return () => {
observer.current?.unobserve(ref);
};
}
}, []);
return (
<div className="custom-scrollbars__container">
<div className="custom-scrollbars__content" ref={contentRef} {...props}>
{children}
</div>
<div className="custom-scrollbars__scrollbar">
<button className="custom-scrollbars__button">⇑</button>
<div className="custom-scrollbars__track-and-thumb">
<div className="custom-scrollbars__track"></div>
<div
className="custom-scrollbars__thumb"
ref={scrollThumbRef}
style={{
height: `${thumbHeight}px`,
}}
></div>
</div>
<button className="custom-scrollbars__button">⇓</button>
</div>
</div>
);
};
export default Scrollbar;
Handle the Position of the Thumb
When we scroll, the thumb should move in proportion to our scrolling. We’ll do this with a useCallback
that is called on the scroll
event. We take the proportion of how far the content has been scrolled (scrollTop
) to the total scrollable area (scrollHeight
), and multiply it by the track’s height to get the new position of the top edge of the thumb. To make sure the thumb doesn’t fly off the end of the track, we cap the top position to no more than the difference between the track’s height and the thumb’s height.
- import React, { useState, useEffect, useRef } from 'react';
+ import React, { useState, useEffect, useRef, useCallback } from 'react';
const handleThumbPosition = useCallback(() => {
if (
!contentRef.current ||
!scrollTrackRef.current ||
!scrollThumbRef.current
) {
return;
}
const { scrollTop: contentTop, scrollHeight: contentHeight } =
contentRef.current;
const { clientHeight: trackHeight } = scrollTrackRef.current;
let newTop = (+contentTop / +contentHeight) * trackHeight;
newTop = Math.min(newTop, trackHeight - thumbHeight);
const thumb = scrollThumbRef.current;
thumb.style.top = `${newTop}px`;
}, []);
useEffect(() => {
if (contentRef.current && scrollTrackRef.current) {
const ref = contentRef.current;
const {clientHeight: trackSize} = scrollTrackRef.current;
observer.current = new ResizeObserver(() => {
handleResize(ref, trackSize);
});
observer.current.observe(ref);
+ ref.addEventListener('scroll', handleThumbPosition);
return () => {
observer.current.unobserve(ref);
+ ref.removeEventListener('scroll', handleThumbPosition);
};
}
}, []);
Handle Clicking the Buttons
Wiring the up and down scroll buttons is very straightforward. We simply create a function that accepts a direction, and scrolls the content by a fixed amount in that direction. Keep in mind that in browsers, the y axis is positive going from top to bottom. That means scrolling down would mean a positive number of pixels, and up would be negative. Then we add this function to the onClick
of our buttons.
function handleScrollButton(direction: 'up' | 'down') {
const { current } = contentRef;
if (current) {
const scrollAmount = direction === 'down' ? 200 : -200;
current.scrollBy({ top: scrollAmount, behavior: 'smooth' });
}
}
<button
className="custom-scrollbars__button"
+ onClick={() => handleScrollButton('up')}
>
⇑
</button>
...
<button
className="custom-scrollbars__button"
+ onClick={() => handleScrollButton('down')}
>
⇓
</button>
Handle Clicking the Track
In most scrollbars, clicking on the scroll track will jump the thumb ahead in that direction. We’ll once again useCallback
to handle clicking on the track and updating the content’s scroll position. The following snippet and comments walk through the steps of figuring out the new position for the content to scroll to.
const handleTrackClick = useCallback(
e => {
e.preventDefault();
e.stopPropagation();
const { current: trackCurrent } = scrollTrackRef;
const { current: contentCurrent } = contentRef;
if (trackCurrent && contentCurrent) {
// First, figure out where we clicked
const { clientY } = e;
// Next, figure out the distance between the top of the track and the top of the viewport
const target = e.target as HTMLDivElement;
const rect = target.getBoundingClientRect();
const trackTop = rect.top;
// We want the middle of the thumb to jump to where we clicked, so we subtract half the thumb's height to offset the position
const thumbOffset = -(thumbHeight / 2);
// Find the ratio of the new position to the total content length using the thumb and track values...
const clickRatio =
(clientY - trackTop + thumbOffset) / trackCurrent.clientHeight;
// ...so that you can compute where the content should scroll to.
const scrollAmount = Math.floor(clickRatio * contentCurrent.scrollHeight);
// And finally, scroll to the new position!
contentCurrent.scrollTo({
top: scrollAmount,
behavior: 'smooth',
});
}
},
[thumbHeight],
);
<div
className="custom-scrollbars__track"
ref={scrollTrackRef}
+ onClick={handleTrackClick}
></div>
Handle Dragging the Thumb
At last, the hardest part: clicking and dragging the thumb to scroll. To do this, we write three functions to handle mousedown
, mousemove
, and mouseup
events. We’ll track whether or not we’re actively dragging, where the mouse was when we started dragging, and where the scroll position of the content was where we started dragging with React state.
The handleThumbMouseup()
function is simplest: it simply sets isDragging
to false so we know we’re not dragging the thumb anymore.
handleThumbMousedown
does a bit more. In addition to setting isDragging
to true, it records the mouse’s y position, and the content’s scrollTop
when you click down, and sets them to scrollStartPosition
and initialScrollTop
respectively. We will use these values to calculate the new scrollTop
for the content.
The handleThumbMousemove()
function is the trickiest. First, we figure out how far the mouse now is (e.clientY
) from where it was when we started (scrollStartPosition
). This tells us the absolute number of pixels the thumb should have moved up or down. But we’ve decoupled the size of the content’s container from the size of the scrollbar track, so pixels of thumb scrolling doesn’t equal the number of pixels the content should scroll. (E.g., if the total content length is 7 times longer than the height of the track, then dragging the thumb by one pixel should scroll the content by 7 pixels.) To scale the value, we multiply it by the ratio of the content’s height to the thumb’s height. Finally, set calculate the new scrollTop
and set it to the content. The thumb’s position will take care of itself thanks to handleThumbPosition()
being called by the scroll
event listener on the content.
const contentRef = useRef<HTMLDivElement>(null);
const scrollTrackRef = useRef<HTMLDivElement>(null);
const scrollThumbRef = useRef<HTMLDivElement>(null);
const observer = useRef<ResizeObserver | null>(null);
const [thumbHeight, setThumbHeight] = useState(20);
+ const [scrollStartPosition, setScrollStartPosition] = useState<number | null>(
null
);
+ const [initialScrollTop, setInitialScrollTop] = useState<number>(0);
+ const [isDragging, setIsDragging] = useState(false);
const handleThumbMousedown = useCallback(e => {
e.preventDefault();
e.stopPropagation();
setScrollStartPosition(e.clientY);
if (contentRef.current) setInitialScrollTop(contentRef.current.scrollTop);
setIsDragging(true);
}, []);
const handleThumbMouseup = useCallback(
e => {
e.preventDefault();
e.stopPropagation();
if (isDragging) {
setIsDragging(false);
}
},
[isDragging],
);
const handleThumbMousemove = useCallback(
e => {
e.preventDefault();
e.stopPropagation();
if (isDragging) {
const {
scrollHeight: contentScrollHeight,
offsetHeight: contentOffsetHeight,
} = contentRef.current;
// Subtract the current mouse y position from where you started to get the pixel difference in mouse position. Multiply by ratio of visible content height to thumb height to scale up the difference for content scrolling.
const deltaY =
(e.clientY - scrollStartPosition) * (contentOffsetHeight / thumbHeight);
const newScrollTop = Math.min(
initialScrollTop + deltaY,
contentScrollHeight - contentOffsetHeight,
);
contentRef.current.scrollTop = newScrollTop;
}
},
[isDragging, scrollStartPosition, thumbHeight],
);
// Listen for mouse events to handle scrolling by dragging the thumb
useEffect(() => {
document.addEventListener('mousemove', handleThumbMousemove);
document.addEventListener('mouseup', handleThumbMouseup);
document.addEventListener('mouseleave', handleThumbMouseup);
return () => {
document.removeEventListener('mousemove', handleThumbMousemove);
document.removeEventListener('mouseup', handleThumbMouseup);
document.removeEventListener('mouseleave', handleThumbMouseup);
};
}, [handleThumbMousemove, handleThumbMouseup]);
<div className="custom-scrollbars__track-and-thumb">
<div
className="custom-scrollbars__track"
ref={scrollTrackRef}
onClick={handleTrackClick}
+ style={{ cursor: isDragging ? 'grabbing' : 'pointer' }}
></div>
<div
className="custom-scrollbars__thumb"
ref={scrollThumbRef}
+ onMouseDown={handleThumbMousedown}
style={{
height: `${thumbHeight}px`,
+ cursor: isDragging ? 'grabbing' : 'grab',
}}
></div>
</div>
Full Code and Demo
That’s it! You’ve now got a completely custom scrollbar in React and TypeScript using DOM elements instead of native scrollbars. You can customize these scrollbars with any kind of CSS you like!
You can see the full code here and play around with the demo.