Smooth Scrolling with React & Framer Motion
IroncladDev

IroncladDev @ironcladdev

About: Texas-based programmer who aims to produce and share great software with the world | Neovim on a Mac, btw

Location:
Dallas TX USA
Joined:
Dec 15, 2020

Smooth Scrolling with React & Framer Motion

Publish Date: Aug 14 '23
68 9

Scrolling through a website especially with a notched mouse wheel is typically jumpy and harder to navigate.

Smooth Scrolling, or spring scrolling adds an animated touch to the traditional mouse scroll.

If you've never experienced smooth scrolling before, try out the Live Demo.

Concept

Consider the viewport window. When a bunch of HTML elements combine to a size taller than the window, it overflows and can be accessed when you scroll down.

Window with Content

To override the default scroll, we need to wrap the content in a fixed element we can control.

Controlled content wrapper

Finally, we will create an invisible spacer (div) equal to the scroll height of the content. This will trigger the browser's default scroll bar.

Invisible height spacer

Setup

Create a React Typescript Repl on Replit to get started.

Install Framer Motion with npm install framer-motion.

The Component

The <SmoothScroll/> Component will wrap all the HTML elements we want to incorporate in the Smooth Scrolling effect.

export default function SmoothScroll({
  children
}: {
  children: React.ReactNode;
}) {
  return <></>;
}
Enter fullscreen mode Exit fullscreen mode

Scroll & Spring Values

Import the following hooks from framer-motion.

import { useScroll, useSpring, useTransform } from 'framer-motion';
Enter fullscreen mode Exit fullscreen mode

In the SmoothScroll component, destructure scrollYProgress from the useScroll hook.

const { scrollYProgress } = useScroll();
Enter fullscreen mode Exit fullscreen mode

Next, use the useSpring hook to apply the smooth effect to the scrollYProgress value.

const smoothProgress = useSpring(scrollYProgress, { mass: 0.1 })
Enter fullscreen mode Exit fullscreen mode

Content & Spacer

Add the motion component to the existing import from framer-motion.

- import { useScroll, useSpring } from 'framer-motion';
+ import { motion, useScroll, useSpring } from 'framer-motion';
Enter fullscreen mode Exit fullscreen mode

Skip down to the component's return statement. Return an empty <div> element and a <motion.div> element which renders the children prop as a child.

return <>
  <div style={{ height: contentHeight }} />

  <motion.div
    className="scrollBody"
  >
    {children}
  </motion.div>
</>
Enter fullscreen mode Exit fullscreen mode

Create a contentRef react reference via the useRef hook and a contentHeight state with useState.

import { useState, useRef } from 'react';

...

const contentRef = useRef<HTMLDivElement>(null);
const [contentHeight, setContentHeight] = useState(0);
Enter fullscreen mode Exit fullscreen mode

Assign the content wrapper the contentRef.

<motion.div
  className="scrollBody"
  ref={contentRef}
>
  {children}
</motion.div>
Enter fullscreen mode Exit fullscreen mode

Use the style prop to make the spacer have a height of contentHeight.

<div style={{ height: contentHeight }} />
Enter fullscreen mode Exit fullscreen mode

Resize Handler

When the window gets resized, the height of the content content is likely to change. Since the contentHeight value is a state, we will need to update it whenever the window resizes, and when the contentRef reference updates.

Start by importing the useEffect hook from react.

- import { useState, useRef } from 'react';
+ import { useEffect, useState, useRef } from 'react';
Enter fullscreen mode Exit fullscreen mode

Within the useEffect hook, create and call a handler to set the contentHeight value to contentRef.current.scrollHeight.

Add contentRef to the dependency array.

useEffect(() => {
  const handleResize = () => {
    if (contentRef.current) {
      setContentHeight(contentRef.current.scrollHeight)
    }
  }

  handleResize();
}, [contentRef]);
Enter fullscreen mode Exit fullscreen mode

Finally, add a resize event listener to window in the useEffect hook, and return a disposer function.

useEffect(() => {
  ...

  window.addEventListener("resize", handleResize);

  return () => {
    window.removeEventListener("resize", handleResize);
  }
}, [contentRef, children]);
Enter fullscreen mode Exit fullscreen mode

Put it all together

Import the useTransform hook from framer-motion.

- import { motion, useScroll, useSpring } from 'framer-motion';
+ import { useTransform, motion, useScroll, useSpring } from 'framer-motion';
Enter fullscreen mode Exit fullscreen mode

Create a constant y and set it to the useTransform hook with smoothProgress as the initial value.

const y = useTransform(smoothProgress, value => {
  return value;
});
Enter fullscreen mode Exit fullscreen mode

Visualizing how to calculate the scroll, we will be subtracting the viewport height from the content container's scroll height, multiplying it by -1, and then by value.

Scroll Visualization

const y = useTransform(smoothProgress, value => {
  return value * -(contentHeight - window.innerHeight);
});
Enter fullscreen mode Exit fullscreen mode

Use the y transformed value in the content container.

<motion.div
  className="scrollBody"
  style={{ y }}
  ref={contentRef}
>
  {children}
</motion.div>
Enter fullscreen mode Exit fullscreen mode

Styles

I already fought CSS so you won't have to. Simply copy and paste the bare minimum CSS over and the scroll component will be ready to roll.

.scrollBody {
  width: 100vw;
  position: fixed;
  top: 0;

  display: flex;
  flex-direction: column;
}
Enter fullscreen mode Exit fullscreen mode

Complete 🎉

That's it! All you have to do is add a bunch of HTML elements within the <SmoothScroll /> component.

Nothing better than fifty <h1>Lorem Ipsum</h1>s in your codebase.


Thanks for reading. If you have any feedback or recommendations for this article, I'd love to hear it in the comments.

Let's get in touch 🤝

Comments 9 total

  • Medea
    MedeaAug 14, 2023

    great post.
    really easy to understand even for a beginner!

  • ABIDULLAH786
    ABIDULLAH786Aug 15, 2023

    Amazing post😍

  • markcwy-ra
    markcwy-raAug 29, 2023

    I'm having an issue where it loads scrolled halfway (but the scrollbar is at the top). when I start scrolling it jumps to the top. I think the y value is loaded before the full content height is loaded. any ideas how to fix this would be amazing!

    • IroncladDev
      IroncladDevSep 2, 2023

      Does this happen in the demo/example I made?

    • Luderio Sanchez
      Luderio SanchezJul 21, 2024

      You can use useLayoutEffect hook instead of useEffect hook to solve the scroll position problem at render. I noticed that the scroll position is not calculating/functioning well using useEffect and after researching, useLayoutEffect is the best one to use on this scenario because it lets you measure the DOM measurement immediately before repaint. see useLayoutEffect documentation

  • Debajyati Dey
    Debajyati DeyJul 6, 2024

    Wow really well written. Easy to understand

  • Luderio Sanchez
    Luderio SanchezJul 21, 2024

    Thank you so much for your article. it helped me a lot on setting up the SmoothScroll on my project.

    One thing I would like to add on this. You can use useLayoutEffect hook instead of useEffect hook to solve the scroll position problem at render. I noticed that the scroll position is not calculating/functioning well using useEffect and after researching, useLayoutEffect is the best one to use on this scenario because it lets you measure the DOM measurement immediately before repaint. see useLayoutEffect documentation

Add comment