DEV Community

reactuse.com
reactuse.com

Posted on

React Scroll Effects Without External Libraries

React Scroll Effects Without External Libraries

Scroll is the most fundamental user interaction on the web. Progress bars that fill as you read, headers that shrink and stick, modals that lock the page behind them, "scroll to section" buttons -- these effects appear on nearly every modern site. Yet implementing them correctly in React means juggling addEventListener, IntersectionObserver, overflow styles, and a surprising number of edge cases. Most developers either pull in a heavy animation library or spend hours writing brittle imperative code.

This post takes a different path. We will tackle six common scroll scenarios, starting each time with the manual implementation so you understand the mechanics, then replacing it with a purpose-built hook from ReactUse (@reactuses/core). ReactUse is an open-source collection of 100+ React hooks that wrap common browser and element interactions into simple, composable APIs. By the end you will have a toolkit of composable, SSR-safe hooks that handle scroll tracking, scroll locking, smooth scrolling, sticky detection, visibility detection, and intersection observing -- all without a single external animation or scroll library.

1. Tracking Scroll Position

The Manual Way

Tracking how far a user has scrolled seems straightforward until you account for throttling, direction detection, and knowing when the user has reached the edges.

import { useEffect, useRef, useState } from "react";

function ManualScrollTracker() {
  const containerRef = useRef<HTMLDivElement>(null);
  const [scrollY, setScrollY] = useState(0);
  const [direction, setDirection] = useState<"up" | "down">("down");
  const lastY = useRef(0);

  useEffect(() => {
    const el = containerRef.current;
    if (!el) return;

    const onScroll = () => {
      const y = el.scrollTop;
      setDirection(y > lastY.current ? "down" : "up");
      lastY.current = y;
      setScrollY(y);
    };

    el.addEventListener("scroll", onScroll, { passive: true });
    return () => el.removeEventListener("scroll", onScroll);
  }, []);

  const progress = containerRef.current
    ? scrollY /
      (containerRef.current.scrollHeight - containerRef.current.clientHeight)
    : 0;

  return (
    <div>
      <div
        style={{
          position: "fixed",
          top: 0,
          left: 0,
          height: 4,
          width: `${progress * 100}%`,
          background: "#4f46e5",
          transition: "width 0.1s",
        }}
      />
      <div
        ref={containerRef}
        style={{ height: "100vh", overflow: "auto" }}
      >
        {/* long content */}
      </div>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

This gets the job done for a simple progress bar. But it does not tell you whether the user has reached the bottom, it has no horizontal scroll tracking, and the direction detection is naive (a single-pixel bounce during momentum scroll will flip it). Adding "arriving at edge" thresholds means even more state and math.

With useScroll

useScroll returns the current x and y offsets, scroll direction on both axes, plus boolean isScrolling and arrivedState fields that tell you whether the user has reached the top, bottom, left, or right edge.

import { useScroll } from "@reactuses/core";
import { useRef } from "react";

function ScrollTracker() {
  const containerRef = useRef<HTMLDivElement>(null);

  const [position, direction, arrivedState, isScrolling] = useScroll(
    containerRef,
    { throttle: 50 }
  );

  const el = containerRef.current;
  const progress = el
    ? position.y / (el.scrollHeight - el.clientHeight)
    : 0;

  return (
    <div>
      {/* Progress bar */}
      <div
        style={{
          position: "fixed",
          top: 0,
          left: 0,
          height: 4,
          width: `${Math.min(progress * 100, 100)}%`,
          background: "#4f46e5",
          zIndex: 50,
        }}
      />

      {/* Scroll info overlay */}
      <div
        style={{
          position: "fixed",
          bottom: 16,
          right: 16,
          padding: "8px 16px",
          background: "#1e293b",
          color: "#fff",
          borderRadius: 8,
          fontSize: 14,
          zIndex: 50,
        }}
      >
        <div>Y: {Math.round(position.y)}px</div>
        <div>Direction: {direction.y ?? "none"}</div>
        <div>
          {arrivedState.bottom
            ? "You reached the bottom!"
            : isScrolling
              ? "Scrolling..."
              : "Idle"}
        </div>
      </div>

      <div
        ref={containerRef}
        style={{ height: "100vh", overflow: "auto" }}
      >
        {Array.from({ length: 100 }, (_, i) => (
          <p key={i} style={{ padding: "8px 16px" }}>
            Paragraph {i + 1}
          </p>
        ))}
      </div>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

One hook call replaces all the manual event wiring, direction tracking, and edge detection. The built-in throttle option keeps performance smooth even on rapid-fire scroll events.

2. Locking Scroll for Modals

The Manual Way

When you open a modal, you need to prevent the page behind it from scrolling. The classic approach is to toggle overflow: hidden on the body:

import { useEffect, useState } from "react";

function ManualModal() {
  const [isOpen, setIsOpen] = useState(false);

  useEffect(() => {
    if (isOpen) {
      const scrollY = window.scrollY;
      document.body.style.position = "fixed";
      document.body.style.top = `-${scrollY}px`;
      document.body.style.width = "100%";
      document.body.style.overflow = "hidden";

      return () => {
        document.body.style.position = "";
        document.body.style.top = "";
        document.body.style.width = "";
        document.body.style.overflow = "";
        window.scrollTo(0, scrollY);
      };
    }
  }, [isOpen]);

  return (
    <>
      <button onClick={() => setIsOpen(true)}>Open Modal</button>
      {isOpen && (
        <div
          style={{
            position: "fixed",
            inset: 0,
            background: "rgba(0,0,0,0.5)",
            display: "flex",
            alignItems: "center",
            justifyContent: "center",
            zIndex: 100,
          }}
        >
          <div
            style={{
              background: "#fff",
              padding: 24,
              borderRadius: 12,
              maxWidth: 400,
            }}
          >
            <h2>Modal Title</h2>
            <p>The page behind this modal cannot scroll.</p>
            <button onClick={() => setIsOpen(false)}>Close</button>
          </div>
        </div>
      )}
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

This works on desktop browsers, but the position: fixed trick causes the page to jump on iOS Safari if you don't carefully save and restore the scroll position. It also does not handle multiple overlapping modals or scrollable containers other than the body.

With useScrollLock

useScrollLock handles all these edge cases. Pass it a ref to the element you want to lock -- typically document.body -- and a boolean to control the lock state.

import { useScrollLock } from "@reactuses/core";
import { useState } from "react";

function Modal() {
  const [isOpen, setIsOpen] = useState(false);

  useScrollLock(
    typeof document !== "undefined" ? document.body : null,
    isOpen
  );

  return (
    <>
      <button onClick={() => setIsOpen(true)}>Open Modal</button>
      {isOpen && (
        <div
          style={{
            position: "fixed",
            inset: 0,
            background: "rgba(0,0,0,0.5)",
            display: "flex",
            alignItems: "center",
            justifyContent: "center",
            zIndex: 100,
          }}
        >
          <div
            style={{
              background: "#fff",
              padding: 24,
              borderRadius: 12,
              maxWidth: 400,
            }}
          >
            <h2>Modal Title</h2>
            <p>Scroll is locked. Try scrolling the page behind.</p>
            <button onClick={() => setIsOpen(false)}>Close</button>
          </div>
        </div>
      )}
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

One line to lock, automatic unlock on unmount, SSR-safe with the document guard. The scroll position is preserved correctly across all browsers.

3. Smooth Scroll to a Section

The Manual Way

"Scroll to" buttons that jump to a section on the page are common in landing pages. The imperative approach:

import { useRef } from "react";

function ManualScrollTo() {
  const sectionRef = useRef<HTMLDivElement>(null);

  const scrollToSection = () => {
    sectionRef.current?.scrollIntoView({
      behavior: "smooth",
      block: "start",
    });
  };

  return (
    <div>
      <nav style={{ position: "fixed", top: 0, padding: 16, zIndex: 10 }}>
        <button onClick={scrollToSection}>Go to Features</button>
      </nav>

      <div style={{ height: "100vh", background: "#f1f5f9" }}>
        <h1 style={{ paddingTop: 80 }}>Hero Section</h1>
      </div>

      <div ref={sectionRef} style={{ padding: 40 }}>
        <h2>Features</h2>
        <p>Feature content here...</p>
      </div>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

scrollIntoView is fine for basic cases, but it does not let you control the easing curve, the scroll axis, or the offset (important when you have a fixed header that would overlap the target). There is also no way to know when the scrolling animation has completed.

With useScrollIntoView

useScrollIntoView gives you fine-grained control over the scroll animation, including custom duration, easing, axis, offset, and an onComplete callback.

import { useScrollIntoView } from "@reactuses/core";
import { useRef } from "react";

function SmoothScrollPage() {
  const targetRef = useRef<HTMLDivElement>(null);

  const { scrollIntoView } = useScrollIntoView(targetRef, {
    duration: 800,
    offset: 80, // account for a fixed header
  });

  return (
    <div>
      <nav
        style={{
          position: "fixed",
          top: 0,
          left: 0,
          right: 0,
          height: 64,
          background: "#1e293b",
          display: "flex",
          alignItems: "center",
          padding: "0 24px",
          zIndex: 50,
        }}
      >
        <button
          onClick={() => scrollIntoView({ alignment: "start" })}
          style={{
            background: "#4f46e5",
            color: "#fff",
            border: "none",
            padding: "8px 16px",
            borderRadius: 6,
            cursor: "pointer",
          }}
        >
          Jump to Pricing
        </button>
      </nav>

      <div style={{ height: "150vh", paddingTop: 80 }}>
        <h1>Hero</h1>
        <p>Scroll down or click the button above.</p>
      </div>

      <div ref={targetRef} style={{ padding: 40, background: "#eef2ff" }}>
        <h2>Pricing</h2>
        <p>Plans and pricing details...</p>
      </div>

      <div style={{ height: "100vh" }} />
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

The offset option ensures the section appears below your fixed header rather than being hidden behind it. The smooth animation uses a configurable easing function, and the hook cleans up properly if the component unmounts mid-scroll.

4. Detecting Sticky Headers

The Manual Way

A common pattern is to change a header's appearance once it becomes sticky (adding a shadow, shrinking its height, etc.). Detecting this manually requires an IntersectionObserver with a sentinel element:

import { useEffect, useRef, useState } from "react";

function ManualStickyHeader() {
  const sentinelRef = useRef<HTMLDivElement>(null);
  const [isStuck, setIsStuck] = useState(false);

  useEffect(() => {
    const sentinel = sentinelRef.current;
    if (!sentinel) return;

    const observer = new IntersectionObserver(
      ([entry]) => {
        setIsStuck(!entry.isIntersecting);
      },
      { threshold: 0 }
    );

    observer.observe(sentinel);
    return () => observer.disconnect();
  }, []);

  return (
    <div>
      {/* Sentinel: when this scrolls out of view, the header is stuck */}
      <div ref={sentinelRef} style={{ height: 1 }} />
      <header
        style={{
          position: "sticky",
          top: 0,
          padding: isStuck ? "8px 24px" : "16px 24px",
          background: isStuck ? "rgba(255,255,255,0.95)" : "#fff",
          boxShadow: isStuck ? "0 2px 8px rgba(0,0,0,0.1)" : "none",
          transition: "all 0.2s",
          zIndex: 40,
        }}
      >
        <h1 style={{ margin: 0, fontSize: isStuck ? 18 : 24 }}>
          My App
        </h1>
      </header>
      <main style={{ padding: 24 }}>
        {Array.from({ length: 80 }, (_, i) => (
          <p key={i}>Content paragraph {i + 1}</p>
        ))}
      </main>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

This sentinel-based approach works but is fragile. You need to position the sentinel correctly, manage the observer lifecycle, and keep the sentinel in sync if the DOM structure changes.

With useSticky

useSticky handles sticky detection cleanly. It returns a boolean that flips to true when the element enters its stuck state.

import { useSticky } from "@reactuses/core";
import { useRef } from "react";

function StickyHeader() {
  const headerRef = useRef<HTMLElement>(null);
  const [isStuck] = useSticky(headerRef);

  return (
    <div>
      <header
        ref={headerRef}
        style={{
          position: "sticky",
          top: 0,
          padding: isStuck ? "8px 24px" : "16px 24px",
          background: isStuck
            ? "rgba(255,255,255,0.95)"
            : "#fff",
          boxShadow: isStuck
            ? "0 2px 8px rgba(0,0,0,0.1)"
            : "none",
          transition: "all 0.2s",
          zIndex: 40,
        }}
      >
        <h1 style={{ margin: 0, fontSize: isStuck ? 18 : 24 }}>
          My App
        </h1>
      </header>
      <main style={{ padding: 24 }}>
        {Array.from({ length: 80 }, (_, i) => (
          <p key={i}>Content paragraph {i + 1}</p>
        ))}
      </main>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

No sentinel elements. No manual observer setup. The hook does the detection internally and gives you a single reactive boolean to drive your styles.

5. Revealing Elements on Scroll

The Manual Way

Fade-in-on-scroll effects are everywhere. The standard approach is to set up an IntersectionObserver for each animated element:

import { useEffect, useRef, useState } from "react";

function ManualReveal({ children }: { children: React.ReactNode }) {
  const ref = useRef<HTMLDivElement>(null);
  const [isVisible, setIsVisible] = useState(false);

  useEffect(() => {
    const el = ref.current;
    if (!el) return;

    const observer = new IntersectionObserver(
      ([entry]) => {
        if (entry.isIntersecting) {
          setIsVisible(true);
          observer.unobserve(el); // only animate once
        }
      },
      { threshold: 0.1 }
    );

    observer.observe(el);
    return () => observer.disconnect();
  }, []);

  return (
    <div
      ref={ref}
      style={{
        opacity: isVisible ? 1 : 0,
        transform: isVisible ? "translateY(0)" : "translateY(30px)",
        transition: "opacity 0.6s ease, transform 0.6s ease",
      }}
    >
      {children}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Copy-pasting this observer logic into every component that needs a reveal animation quickly becomes tedious.

With useElementVisibility

useElementVisibility wraps IntersectionObserver into a single boolean return value. Pair it with a useState flag to create one-time reveal effects:

import { useElementVisibility } from "@reactuses/core";
import { useRef, useState, useEffect } from "react";

function RevealOnScroll({ children }: { children: React.ReactNode }) {
  const ref = useRef<HTMLDivElement>(null);
  const [visible] = useElementVisibility(ref);
  const [hasRevealed, setHasRevealed] = useState(false);

  useEffect(() => {
    if (visible && !hasRevealed) {
      setHasRevealed(true);
    }
  }, [visible, hasRevealed]);

  return (
    <div
      ref={ref}
      style={{
        opacity: hasRevealed ? 1 : 0,
        transform: hasRevealed ? "translateY(0)" : "translateY(30px)",
        transition: "opacity 0.6s ease, transform 0.6s ease",
      }}
    >
      {children}
    </div>
  );
}

function FeaturePage() {
  return (
    <div style={{ padding: "100vh 24px 24px" }}>
      <RevealOnScroll>
        <h2>Feature One</h2>
        <p>This fades in when scrolled into view.</p>
      </RevealOnScroll>
      <div style={{ height: 200 }} />
      <RevealOnScroll>
        <h2>Feature Two</h2>
        <p>Each section animates independently.</p>
      </RevealOnScroll>
      <div style={{ height: 200 }} />
      <RevealOnScroll>
        <h2>Feature Three</h2>
        <p>And they only animate once -- no flickering on scroll back.</p>
      </RevealOnScroll>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

useElementVisibility gives you the live visibility state. The hasRevealed flag ensures the animation only fires once. For repeating animations, simply drop the flag and drive the style directly from visible.

6. Advanced Intersection: Scroll-Linked Progress

The Manual Way

For more advanced scroll effects -- like a progress indicator that fills as you scroll through a specific section -- you need fine-grained intersection ratios:

import { useEffect, useRef, useState } from "react";

function ManualSectionProgress() {
  const sectionRef = useRef<HTMLDivElement>(null);
  const [ratio, setRatio] = useState(0);

  useEffect(() => {
    const el = sectionRef.current;
    if (!el) return;

    const thresholds = Array.from({ length: 101 }, (_, i) => i / 100);
    const observer = new IntersectionObserver(
      ([entry]) => setRatio(entry.intersectionRatio),
      { threshold: thresholds }
    );

    observer.observe(el);
    return () => observer.disconnect();
  }, []);

  return (
    <div>
      <div style={{ height: "100vh" }} />
      <div ref={sectionRef} style={{ minHeight: "100vh", padding: 40 }}>
        <div
          style={{
            position: "sticky",
            top: 20,
            width: 200,
            height: 8,
            background: "#e2e8f0",
            borderRadius: 4,
          }}
        >
          <div
            style={{
              height: "100%",
              width: `${ratio * 100}%`,
              background: "#4f46e5",
              borderRadius: 4,
              transition: "width 0.1s",
            }}
          />
        </div>
        <h2>Long Section</h2>
        {Array.from({ length: 20 }, (_, i) => (
          <p key={i}>Paragraph {i + 1} of the section.</p>
        ))}
      </div>
      <div style={{ height: "100vh" }} />
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Building 101 threshold steps and managing the observer manually is verbose. And if you want multiple observers with different root margins or targets, the boilerplate multiplies.

With useIntersectionObserver

useIntersectionObserver exposes the full IntersectionObserver API in a declarative way. You get the IntersectionObserverEntry directly, including intersectionRatio, isIntersecting, and boundingClientRect.

import { useIntersectionObserver } from "@reactuses/core";
import { useRef, useState } from "react";

function SectionProgress() {
  const sectionRef = useRef<HTMLDivElement>(null);
  const [ratio, setRatio] = useState(0);

  useIntersectionObserver(
    sectionRef,
    ([entry]) => {
      setRatio(entry.intersectionRatio);
    },
    {
      threshold: Array.from({ length: 101 }, (_, i) => i / 100),
    }
  );

  return (
    <div>
      <div style={{ height: "100vh" }} />
      <div ref={sectionRef} style={{ minHeight: "100vh", padding: 40 }}>
        <div
          style={{
            position: "sticky",
            top: 20,
            width: 200,
            height: 8,
            background: "#e2e8f0",
            borderRadius: 4,
          }}
        >
          <div
            style={{
              height: "100%",
              width: `${ratio * 100}%`,
              background: "#4f46e5",
              borderRadius: 4,
              transition: "width 0.1s",
            }}
          />
        </div>
        <h2>Long Section</h2>
        {Array.from({ length: 20 }, (_, i) => (
          <p key={i}>Paragraph {i + 1} of the section.</p>
        ))}
      </div>
      <div style={{ height: "100vh" }} />
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

The hook manages the observer lifecycle, reconnects when options change, and cleans up on unmount. You focus on what to do with the intersection data, not how to wire the observer.

Putting It All Together

These hooks compose naturally. Here is a landing page skeleton that combines all six:

import {
  useScroll,
  useScrollLock,
  useScrollIntoView,
} from "@reactuses/core";
import { useSticky, useElementVisibility } from "@reactuses/core";
import { useRef, useState } from "react";

function LandingPage() {
  const scrollContainerRef = useRef<HTMLDivElement>(null);
  const pricingRef = useRef<HTMLDivElement>(null);
  const headerRef = useRef<HTMLElement>(null);

  // Track scroll for progress bar
  const [position] = useScroll(scrollContainerRef);

  // Sticky header detection
  const [isStuck] = useSticky(headerRef);

  // Smooth scroll to pricing
  const { scrollIntoView } = useScrollIntoView(pricingRef, {
    offset: 64,
  });

  // Modal with scroll lock
  const [modalOpen, setModalOpen] = useState(false);
  useScrollLock(
    typeof document !== "undefined" ? document.body : null,
    modalOpen
  );

  // Reveal pricing section
  const [pricingVisible] = useElementVisibility(pricingRef);

  const el = scrollContainerRef.current;
  const progress = el
    ? position.y / (el.scrollHeight - el.clientHeight)
    : 0;

  return (
    <div ref={scrollContainerRef} style={{ height: "100vh", overflow: "auto" }}>
      {/* Progress bar */}
      <div
        style={{
          position: "fixed",
          top: 0,
          left: 0,
          height: 3,
          width: `${Math.min(progress * 100, 100)}%`,
          background: "#4f46e5",
          zIndex: 60,
        }}
      />

      {/* Sticky header */}
      <header
        ref={headerRef}
        style={{
          position: "sticky",
          top: 0,
          padding: isStuck ? "8px 24px" : "16px 24px",
          background: "#fff",
          boxShadow: isStuck ? "0 2px 8px rgba(0,0,0,0.1)" : "none",
          transition: "all 0.2s",
          zIndex: 50,
          display: "flex",
          justifyContent: "space-between",
          alignItems: "center",
        }}
      >
        <span style={{ fontWeight: 700 }}>MyApp</span>
        <div style={{ display: "flex", gap: 12 }}>
          <button onClick={() => scrollIntoView()}>Pricing</button>
          <button onClick={() => setModalOpen(true)}>Contact</button>
        </div>
      </header>

      {/* Hero */}
      <section style={{ height: "100vh", padding: 40 }}>
        <h1>Build amazing products</h1>
      </section>

      {/* Pricing with reveal */}
      <section
        ref={pricingRef}
        style={{
          padding: 40,
          opacity: pricingVisible ? 1 : 0,
          transform: pricingVisible ? "none" : "translateY(30px)",
          transition: "all 0.6s ease",
        }}
      >
        <h2>Pricing</h2>
        <p>Plans and details here.</p>
      </section>

      <div style={{ height: "100vh" }} />

      {/* Modal */}
      {modalOpen && (
        <div
          style={{
            position: "fixed",
            inset: 0,
            background: "rgba(0,0,0,0.5)",
            display: "flex",
            alignItems: "center",
            justifyContent: "center",
            zIndex: 100,
          }}
        >
          <div
            style={{
              background: "#fff",
              padding: 24,
              borderRadius: 12,
              maxWidth: 400,
            }}
          >
            <h2>Contact Us</h2>
            <p>Page scroll is locked while this modal is open.</p>
            <button onClick={() => setModalOpen(false)}>Close</button>
          </div>
        </div>
      )}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Each hook handles one responsibility. They share refs naturally, do not conflict with each other, and all clean up automatically on unmount.

Installation

npm i @reactuses/core
Enter fullscreen mode Exit fullscreen mode

Related Hooks


ReactUse provides 100+ hooks for React. Explore them all →

Top comments (0)