DEV Community

Cover image for Smooth Scrolling with React & Framer Motion
Conner Ow
Conner Ow

Posted on

Smooth Scrolling with React & Framer Motion

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 🀝

Top comments (9)

Collapse
 
vulcanwm profile image
Medea

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

Collapse
 
ironcladdev profile image
Conner Ow

Appreciate it, thanks :)

Collapse
 
abidullah786 profile image
ABIDULLAH786

Amazing post😍

Collapse
 
ironcladdev profile image
Conner Ow

Thank you!!

Collapse
 
markcwy profile image
markcwy-ra

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!

Collapse
 
ironcladdev profile image
Conner Ow

Does this happen in the demo/example I made?

Collapse
 
luderio_sanchez_a258d570f profile image
Luderio Sanchez

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

Collapse
 
luderio_sanchez_a258d570f profile image
Luderio Sanchez

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

Collapse
 
ddebajyati profile image
Debajyati Dey

Wow really well written. Easy to understand