Smooth Scroll to Element with React and Vanilla JavaScript
Harry Programmer and the philosopher's scroll
It's quite common to see a button that would scroll the view to a specific element after clicking on it.
But how do we do that?
If you are already using gsap in your project, then you should definitely be using the scrollToPlugin.
gsap.to(window, {duration: 2, scrollTo:"#someId"});
Boom! Done ✅
The problem
But you often might find yourself in a situation when including a 58.3kB library just for this particular effect just isn't worth it. You could also use jQuery, but let's be honest, it's 2021. So we are going for either vanilla javascript or any other modern framework of your choosing. We will use React and Vanilla JavaScript in our example. (Both examples will be shown below).
Also, simple scroll behavior is still not supported in Safari, but you want that smooth scroll effect for all the browsers right? And maybe even set some custom duration of the animation and easing, huh?
If that is the case, then this might be the solution just for you.
The solution
If you want to skip to the solution, right away, here it is:
React:
Vanilla:
Tutorial: How to scroll to element
We will start with the markup, which is pretty straightforward, there will be few sections which we will scroll through, two buttons firing the scroll animation on click, one with id and the other with react ref as a target:
The App
// App.js
import React from "react";
import "./styles.css";
import ScrollToButton from "./components/ScrollToButton";
import Section from "./components/Section";
const sections = ["intro", "description", "contact", "footer"];
export default function App() {
const descriptionRef = React.useRef(null);
return (
<div className="App">
<h1>Hello World.</h1>
<h2>Click on the button see some magic happen!</h2>
<ScrollToButton toId="contact">Scroll To Contact!</ScrollToButton>
<Section title={sections[0]} />
<Section ref={descriptionRef} title={sections[1]} />
<Section id={sections[2]} title={sections[2]}>
<ScrollToButton duration={1500} toRef={descriptionRef}>
Scroll To Description!
</ScrollToButton>
</Section>
<Section title={sections[3]} />
</div>
);
}
The Button
The button should take either the id of the element or react reference as the target we want to scroll to, it might be handy to set custom duration, imagine you want to scroll just one section above the current, 3 seconds might be too much right? So it will accept duration as well, and the last prop is children, which in our case will be just text.
// ScrollToButton.jsx
import React from "react";
import { scrollTo } from "../utils";
const ScrollToButton = ({ toId, toRef, duration, children }) => {
const handleClick = () => scrollTo({ id: toId, ref: toRef, duration });
return <button onClick={handleClick}>{children}</button>;
};
export default ScrollToButton;
The Section
Our Section component is just a simple container that would accept id or ref just to showcase purposes. Also title and children. Notice the function definition with ref forwarding this is the right way of passing a ref that we want to use in our DOM.
// Section.jsx
import React from "react";
const ScrollToButton = React.forwardRef(({ id, title, children }, ref) => (
<section ref={ref} id={id}>
<h2>{title}</h2>
{children}
</section>
));
export default ScrollToButton;
The styles
While this already is something, it looks somewhat dull doesn't it? So let's add some swag! We have set each section to be 100% height of the viewport so we can manifest the scrolling without worrying too much about the actual content.
/* styles.css */
.App {
font-family: sans-serif;
text-align: center;
}
section {
height: 100vh;
border-bottom: 1px solid blue;
padding-top: 30px;
}
button {
cursor: pointer;
background: transparent;
color: #0000ff;
border: none;
border-bottom: 2px solid #0000ff;
font-weight: bold;
font-size: 1rem;
padding-left: 0px;
padding-right: 0px;
padding-bottom: 5px;
}
The wrapper
Now let’s add the wrapper of our utility, we decided to support both Id and react ref as a target element, so our wrapper function would look something like this. It only decides what type of reference that is and calls the main function with the target and initial position.
It also checks if the provided element is actually valid and if not it prints the error message to the console.
We are using default value for duration so we don't have to set it on every instance of the button.
As this is the point where the fun begins, please refer to the comments in the code.
// scrollTo.js
import { animateScroll } from "./animateScroll";
const logError = () =>
console.error(
`Invalid element, are you sure you've provided element id or react ref?`
);
const getElementPosition = (element) => element.offsetTop;
export const scrollTo = ({ id, ref = null, duration = 3000 }) => {
// the position of the scroll bar before the user clicks the button
const initialPosition = window.scrollY;
// decide what type of reference that is
// if neither ref or id is provided set element to null
const element = ref ? ref.current : id ? document.getElementById(id) : null;
if (!element) {
// log error if the reference passed is invalid
logError();
return;
}
animateScroll({
targetPosition: getElementPosition(element),
initialPosition,
duration
});
};
The animation
First of all, we can think about the easing function we can use. I’ve decided to use easeOutQuart since it feels natural when the animation is decelerating over time. But if you don't like this one you can go crazy and pick your own here. You will find out that each of these functions just takes the variable x as the absolute progress of the animation in the bounds of 0 (beginning of the animation) and 1 (end of animation).
We will then calculate the relative progress of the whole animation and pass it to this function.
Very important part of the animateScroll function is requestAnimationFrame. Before this feature, developers had to use for loops and throttling to achieve similar effect, which was a huge performance issue, this method, however, always tries to ensure 60fps and if it's not possible it just slows itself.
You should call this method whenever you're ready to update your animation onscreen. This will request that your animation function be called before the browser performs the next repaint. The number of callbacks is usually 60 times per second, but will generally match the display refresh rate in most web browsers as per W3C recommendation. — developer.mozzila.org
// animateScroll.js
const pow = Math.pow;
// The easing function that makes the scroll decelerate over time
function easeOutQuart(x) {
return 1 - pow(1 - x, 4);
}
export function animateScroll({ targetPosition, initialPosition, duration }) {
let start;
let position;
let animationFrame;
const requestAnimationFrame = window.requestAnimationFrame;
const cancelAnimationFrame = window.cancelAnimationFrame;
// maximum amount of pixels we can scroll
const maxAvailableScroll =
document.documentElement.scrollHeight -
document.documentElement.clientHeight;
const amountOfPixelsToScroll = initialPosition - targetPosition;
function step(timestamp) {
if (start === undefined) {
start = timestamp;
}
const elapsed = timestamp - start;
// this just gives us a number between 0 (start) and 1 (end)
const relativeProgress = elapsed / duration;
// ease out that number
const easedProgress = easeOutQuart(relativeProgress);
// calculate new position for every thick of the requesAnimationFrame
position =
initialPosition - amountOfPixelsToScroll * Math.min(easedProgress, 1);
// set the scrollbar position
window.scrollTo(0, position);
// Stop when max scroll is reached
if (
initialPosition !== maxAvailableScroll &&
window.scrollY === maxAvailableScroll
) {
cancelAnimationFrame(animationFrame);
return;
}
// repeat until the end is reached
if (elapsed < duration) {
animationFrame = requestAnimationFrame(step);
}
}
animationFrame = requestAnimationFrame(step);
}
And that's it! You can even customize it to scroll horizontally or maybe support scrolling inside specific elements but for the sake of clarity we are not showing it here. But I guess right now you've got the idea of the main logic behind this, so it will be quite easy to add these features in case you need them. Or would you rather see that extension as a part two? Let us know in the comments below!