Building a Custom Horizontal Scrollbar in React with Framer Motion

Leveraging the drag and animation features of Framer Motion to create a smooth dynamic scrollbar.

March 21, 2025

By Craig Hicks

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 and offset: Framer Motion MotionValues 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.

  1. 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 are MotionValue<number> objects from Framer Motion. A MotionValue 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 parent div. Helps us measure its width to set drag boundaries and position calculations.
  2. 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.

  3. 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.
  4. 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.
  5. 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 bind x: offset so that the thumb’s position can be controlled via a MotionValue. That means if other code updates offset, 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!

Further Reading

https://motion.dev/docs/react-motion-component

A photo of Craig Hicks, an employee at Fishtank

Craig Hicks

Front End Developer

Craig Hicks (or ‘chicks’ for short) is a seasoned developer whose expertise spans web development, digital media, project management, and leadership. Throughout his career he has evolved from hands-on coding roles to strategic management positions, and aims to apply his experience into his passion for problem-solving and development. His love for continuous learning and diving into new challenges applies to both his professional life and personal pursuits. Outside of work, he enjoys music, movies, and sports with friends and family.