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

Published: (March 2, 2026 at 11:58 AM EST)
3 min read
Source: Dev.to

Source: Dev.to

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. Every single pixel your user scrolls triggers a state update, forcing the 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, 300 px). We track a single boolean value, meaning React only re‑renders twice—once when the button appears, and once when it vanishes.

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

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

    useEffect(() => {
        const handleScroll = () => {
            // Toggle state only when crossing the 300px mark
            setIsVisible(window.scrollY > 300);
        };

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

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

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

    return (
        
            
                Scroll to Top ⇑
            
        
    );
};

export default ScrollToTop;

The Styling: Ditch display: none

You cannot animate an element that toggles between display: block and display: none; it snaps in and out of existence. To achieve a buttery‑smooth fade, we use opacity and visibility, pairing them 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 */
}

Why This Component Is a Winner

  • Zero Drag – By ditching pixel‑tracking, the browser can breathe easy.
  • Passive Listeners{ passive: true } tells the browser the listener won’t prevent the default scroll behavior, keeping scrolling perfectly smooth.
  • Accessible & Clean – The aria-label keeps it friendly for screen readers, and the CSS animations give it a premium UI feel.

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?

0 views
Back to Blog

Related posts

Read more »