DEV Community

Cover image for Stop Murdering Your React App: A Buttery Smooth Scroll-To-Top Component
Christopher Cameron
Christopher Cameron

Posted on

Stop Murdering Your React App: A Buttery Smooth Scroll-To-Top Component

We’ve all been there. You’re reading a brilliant, massive piece of content, you hit the bottom, and suddenly you have to manually scroll all the way back up like it’s 2004.

Dropping a "Scroll to Top" button into your React app is a no-brainer for user experience. But if you aren't careful, that tiny little convenience button can absolutely tank your app's performance.

Today, I’m going to share a highly optimized, buttery smooth ScrollToTop component that rescues your users without causing a chaotic avalanche of React re-renders.

The Trap: Tracking Every Single Pixel

When I first built one of these, I fell into the classic trap: storing the exact window.scrollY position in React state.

Here is the brutal reality of that approach: every single pixel your user scrolls triggers a state update. If they scroll down a long page, you are forcing your component to re-render hundreds of times a second. It’s a massive resource hog for absolutely no reason.

The Solution: Threshold State & CSS Wizardry

Instead of tracking the exact scroll depth, we only care about one thing: Has the user scrolled past our threshold? (In this case, 300px). We track a single boolean value, meaning React only re-renders twice—once when the button appears, and once when it vanishes.

Here is the optimized component:

import { useState, useEffect } from 'react';
import './ScrollToTop.css';

const ScrollToTop = () => {
    const [isVisible, setIsVisible] = useState(false);

    useEffect(() => {
        const handleScroll = () => {
            // We only toggle state when crossing the 300px mark
            if (window.scrollY > 300) {
                setIsVisible(true);
            } else {
                setIsVisible(false);
            }
        };

        // The { passive: true } option is crucial for scroll performance!
        window.addEventListener('scroll', handleScroll, { passive: true });

        return () => {
            window.removeEventListener('scroll', handleScroll);
        };
    }, []); // Empty array ensures this listener only mounts once

    const handleClick = () => {
        window.scrollTo({
            top: 0,
            behavior: 'smooth'
        });
    };

    return (
        <div className='scroll-to-top-button-div'>
            <button 
                className={`btn ${isVisible ? 'show' : 'hide'}`} 
                type='button' 
                onClick={handleClick}
                aria-label="Scroll to top"
            >
                Scroll to Top &uArr;
            </button>
        </div>
    );
};

export default ScrollToTop;

Enter fullscreen mode Exit fullscreen mode

The Styling: Ditch display: none

You cannot animate an element that toggles between display: block and display: none. It just violently snaps in and out of existence. To get that buttery smooth fade, we use opacity and visibility instead, pairing it with pointer-events: none so the invisible button doesn't block underlying clicks.

.btn {
    padding: 12px 20px;
    border: none;
    border-radius: 8px;
    position: fixed;
    bottom: 20px;
    right: 20px;
    z-index: 1000;
    background-color: slateblue;
    color: white;
    cursor: pointer;
    /* Transition opacity and visibility for a smooth fade */
    transition: opacity 0.4s ease, visibility 0.4s ease, transform 0.3s ease;
    box-shadow: 0 4px 6px rgba(0,0,0,0.2);
}

.btn:hover {
    background-color: orange;
    transform: translateY(-3px);
}

.show {
    opacity: 0.85;
    visibility: visible;
}

.hide {
    opacity: 0;
    visibility: hidden;
    pointer-events: none; /* Prevents invisible clicks */
}
Enter fullscreen mode Exit fullscreen mode

Why This Component is a Winner
Zero Drag: By ditching pixel-tracking, your browser can breathe easy.

Passive Listeners: Adding { passive: true } tells the browser that our event listener won't prevent the default scroll behaviour, keeping the scroll itself perfectly smooth.

Accessible & Clean: The aria-label keeps it friendly for screen readers, and the CSS animations make it feel like a premium UI feature.

Feel free to snag this for your own projects! Let me know in the comments how you handle scroll events in your React builds—are there any custom hooks you prefer over a standard useEffect?

Top comments (0)