Building a Custom React Scrollbar: Introduction
Sometimes the default browser scrollbar just doesn’t cut it—maybe it clashes with your design system, or you need finer control for a carousel or slider component. In this post, we’ll learn how to build a React-based scrollbar from scratch, leveraging Framer Motion to handle not only the thumb’s animation but also the underlying content’s scroll position. This foundation opens the door to richer component optimizations—like momentum scrolling, inertia effects, or dynamic styling—should you decide to take it further. We’ll walk through:
- Setting up the basic React component structure.
- Using Framer Motion to animate and synchronize the content scroll and thumb movement.
- Handling user interactions (clicking and dragging) for a seamless experience.
The Component Overview
While building a larger Slider component, I needed a custom and more visually cohesive scrolling experience than the default browser scrollbar could provide. Other libraries were either much larger than what we required or lacked the customizability needed to fit the design. This base provided a light weight, extensible piece to the larger component and other similar components:
Props:
containerWidth
: The fixed width of the visible container.sliderWidth
: The total width of your scrollable content.thumbWidth
: How wide the draggable thumb should be.x
andoffset
: Framer MotionMotionValue
s that track and map the slider’s current position.scrollBarRef
: A reference to the scrollbar container element for measuring its width.
Early Return:
If the content (sliderWidth
) doesn’t exceed the container width (containerWidth
), we skip rendering the scrollbar altogether.
Rendering:
The component displays a track (the static rail) and a thumb (motion.div
), which the user can drag.
Drag Logic:
As the thumb is dragged, the x
motion value updates, causing the main slider content to move in sync with the thumb’s position.
Click Interaction:
Clicking anywhere on the track also repositions the thumb, letting users jump to a specific scroll position instantly.
This structure strikes a balance between simplicity and flexibility, making it easier to tailor the scrollbar to your design needs.
Let’s break down the code:
import React, { useRef } from 'react';
import { motion, MotionValue } from 'framer-motion';
type ScrollBarProps = {
containerWidth: number;
sliderWidth: number;
thumbWidth: number;
x: MotionValue<number>;
offset: MotionValue<number>;
scrollBarRef: React.RefObject<HTMLDivElement>;
};
const styles = {
scrollBar: {
// Framer Motion's types require 'as const' for CSS position properties
// This ensures the value is treated as a literal type ('absolute') rather than string
// Without this, TypeScript thinks position could be any string, which Framer Motion doesn't allow
position: 'relative' as const,
display: 'flex',
height: '8px',
width: '100%',
cursor: 'pointer',
alignItems: 'center',
},
track: {
height: '2px',
width: '100%',
backgroundColor: 'lightGrey',
},
thumb: {
position: 'absolute' as const,
height: '8px',
cursor: 'grab',
backgroundColor: 'darkGrey',
},
};
const SliderScrollBar = ({
containerWidth,
thumbWidth,
sliderWidth,
x,
offset,
scrollBarRef,
}: ScrollBarProps) => {
const thumbRef = useRef<HTMLDivElement>(null);
// If the content doesn't need scrolling, no scrollbar
if (sliderWidth <= containerWidth) return null;
const handleThumbDrag = () => {
const thumb = thumbRef.current;
const scrollBar = scrollBarRef.current;
if (!thumb || !scrollBar) return;
// Calculate the current x position of the thumb relative to the track
const thumbX =
thumb.getBoundingClientRect().x - scrollBar.getBoundingClientRect().x;
// We need the width of the scroll track minus the thumb width to find the valid drag range
const denominator = scrollBar.clientWidth - thumbWidth;
if (denominator <= 0) return;
// Convert the absolute position to a percentage along the track
const xPercent = thumbX / denominator;
// Use that percentage to figure out where the main slider content should be
const sliderNewX = xPercent * (containerWidth - sliderWidth);
x.set(sliderNewX);
};
const handleScrollBarClick = (event: React.MouseEvent<HTMLDivElement>) => {
if (event.target === thumbRef.current) return;
const scrollBar = scrollBarRef.current;
if (!scrollBar) return;
// Calculate where the user clicked in relation to the scrollbar
const bounds = scrollBar.getBoundingClientRect();
const clickX = event.clientX - bounds.left;
const scrollPercent = clickX / bounds.width;
// Constrain the percentage between 0 and 1
const clampedPercent = Math.max(0, Math.min(1, scrollPercent));
// Convert that percentage to the main slider’s X position
const newX = (containerWidth - sliderWidth) * clampedPercent;
x.set(newX);
};
return (
<div
style={styles.scrollBar}
onClick={handleScrollBarClick}
ref={scrollBarRef}
>
{/* Track - the thin line that represents the full scrollable width */}
<div style={styles.track} />
{/*
Thumb - the draggable element that shows current scroll position
- Uses Framer Motion for smooth drag interactions
- Position is controlled by the 'offset' motion value
- Constrained to the width of the scroll bar
*/}
<motion.div
ref={thumbRef}
style={{
...styles.thumb,
width: thumbWidth,
x: offset,
}}
drag="x" // Enable horizontal dragging
dragConstraints={{
left: 0,
right: containerWidth - thumbWidth,
}}
dragMomentum={false} // Disable momentum for precise control
dragElastic={false} // Disable elastic dragging beyond constraints
onDrag={handleThumbDrag}
/>
</div>
);
};
export default SliderScrollBar;
Let’s Break Down the Key Parts
Here we’ll take a closer look at the core logic that drives our custom scrollbar—from the props that define its dimensions and motion values, to the event handlers that keep the thumb and content in sync. Each part plays a distinct role in delivering a smooth, interactive scrolling experience.
- Props:
containerWidth
vs.sliderWidth
: We need these to know if we actually have enough content to scroll and how far the thumb can travel.x
&offset
: These areMotionValue<number>
objects from Framer Motion. AMotionValue
is a special object that can synchronize animations and allow for more precise updates.thumbWidth
: Used to calculate the drag constraints so the thumb doesn’t move outside the track boundaries.scrollBarRef
: A reference to the parentdiv
. Helps us measure its width to set drag boundaries and position calculations.
-
Early Exit:
if (sliderWidth <= containerWidth) return null;
If there isn’t more content than the container can show, we don’t need a scrollbar at all.
-
handleThumbDrag()
:- Invoked whenever the thumb is being dragged.
- It reads the current thumb position by comparing bounding rectangles (
thumb.getBoundingClientRect()
vs.scrollBar.getBoundingClientRect()
). - Calculates a percentage (
xPercent
) of how far the user has dragged along the scroll bar. - Converts that percentage into a new
x
position for the main slider content.
handleScrollBarClick()
:- Called if the user clicks anywhere on the track (but not on the thumb itself).
- By determining the click position (
clickX
) relative to the track, we can figure out the new scroll percentage. - The new scroll percentage is applied to the main content’s
x
value so the content jumps (or animates, if we wanted) to that position.
-
The
thumb
with Framer Motion:<motion.div ref={thumbRef} style={{ ...styles.thumb, // Spread the CSS styles width: thumbWidth, // Add the dynamic properties for Framer Motion x: offset, // Add the dynamic properties for Framer Motion }} drag="x" // Enable horizontal dragging dragConstraints={{ left: 0, right: containerWidth - thumbWidth, }} dragMomentum={false} // Disable momentum for precise control dragElastic={false} // Disable elastic dragging beyond constraints onDrag={handleThumbDrag} />
style={{ width: thumbWidth, x: offset }}
: We bindx: offset
so that the thumb’s position can be controlled via aMotionValue
. That means if other code updatesoffset
, the thumb will move.drag="x"
: Enables horizontal dragging in Framer Motion.dragConstraints
: Ensures the thumb cannot be dragged beyond the track’s boundaries.dragMomentum={false}
&dragElastic={false}
: This disables the inertial “throwing” effect and the “springy” boundary effect. We want to lock the user strictly within the track.
Let’s Put the Scrollbar in Action with This Usage Example
In this example, we first define three key width values—containerWidth
(the visible area), sliderWidth
(the total width of the scrollable content), and thumbWidth
(the width of the scrollbar thumb).
Next, we create a useMotionValue
called x
which tracks the slider’s horizontal position without triggering unnecessary re-renders.
We then use useTransform
to derive a second motion value, offset
, so that when x
changes (as the user scrolls or drags), the thumb’s position in the custom scrollbar (offset
) updates in parallel. Specifically, when the slider content is fully left (x = 0
), offset
becomes 0—placing the thumb at the far left; when the content moves fully right (x = containerWidth - sliderWidth
), the thumb snaps to the far right (offset = containerWidth - thumbWidth
).
Finally, we pass all these pieces—x
, offset
, widths, and a reference to the scrollbar’s container—to the SliderScrollBar
component. That component uses the motion values to keep the track and thumb positions perfectly in sync as the user clicks, drags, or otherwise manipulates the scrollbar.
import { useRef } from 'react';
import { motion, useMotionValue, useTransform } from 'framer-motion';
import SliderScrollBar from './SliderScrollBar';
function Slider() {
// Suppose our container is 600px wide, while content is 1280px wide
const containerWidth = 600;
const sliderWidth = 1280;
const thumbWidth = 50;
// Track the horizontal position of the slider content
// useMotionValue creates an efficient, non-rendering state value
// that can be updated frequently during animations without
// causing unnecessary component re-renders
const x = useMotionValue(0);
// Create a motion value for the scrollbar thumb position
// Maps the content's x position to the thumb's x position:
// - When content is at x=0 (start), thumb is at x=0
// - When content is at its leftmost position (containerWidth - sliderWidth),
// thumb is at its rightmost position (containerWidth - thumbWidth)
const offset = useTransform(
x,
[0, containerWidth - sliderWidth],
[0, containerWidth - thumbWidth]
);
const scrollBarRef = useRef<HTMLDivElement>(null);
return (
<div
className="overflow-hidden"
style={{
width: containerWidth,
overflow: 'hidden',
}}
>
{/* Main motion container that handles horizontal movement */}
<motion.div style={{ x }}>
{/* Container with fixed width to ensure proper scrolling bounds */}
<div style={{ width: sliderWidth }}>
{/*
Flex container for the colored squares
- Uses gap for consistent spacing between items
- Padding adds space at top and bottom
*/}
<div
style={{
display: 'flex',
gap: '1rem',
}}
>
{/*
Generate array of colored squares
- Creates 6 squares with different hues
- Each square is fixed width/height
*/}
{[...Array(6)].map((_, index) => (
<div
key={index}
style={{
width: '200px',
height: '200px',
backgroundColor: `hsl(${index * 20}, 70%, 50%)`, // Incremental hue for rainbow effect
borderRadius: '8px',
}}
/>
))}
</div>
</div>
</motion.div>
<div ref={scrollBarRef} style={{ marginTop: '1rem' }}>
<SliderScrollBar
containerWidth={containerWidth}
sliderWidth={sliderWidth}
thumbWidth={thumbWidth}
x={x}
offset={offset}
scrollBarRef={scrollBarRef}
/>
</div>
</div>
);
}
export default Slider;
In Summary: Using Framer Motion for Custom Scrollbar
By leveraging React for rendering, Framer Motion for smooth drag interactions, and standard DOM APIs for measuring element positions, we’ve built a fully customizable scrollbar that can adapt to various layouts. This approach grants you fine-grained control over the scroll experience—everything from how the thumb looks and feels to how it responds to user clicks and drags. You can easily extend this solution with momentum effects, keyboard navigation, or alternative styling to suit your project’s design system.
See the Live Demo on StackBlitz.
Happy scrolling!