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>
);
}
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>
);
}
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>
)}
</>
);
}
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>
)}
</>
);
}
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>
);
}
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>
);
}
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>
);
}
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>
);
}
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>
);
}
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>
);
}
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>
);
}
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>
);
}
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>
);
}
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
Related Hooks
-
useScroll-- Track scroll position, direction, and edge arrival -
useScrollLock-- Lock scroll on any element -
useScrollIntoView-- Smooth scroll to a target element with offset and easing -
useSticky-- Detect when an element enters its sticky state -
useElementVisibility-- Check if an element is visible in the viewport -
useIntersectionObserver-- Full-featured intersection observation -
useEventListener-- Attach event listeners declaratively -
useElementSize-- Track element dimensions reactively -
useElementBounding-- Get live bounding rectangle of an element
ReactUse provides 100+ hooks for React. Explore them all →
Top comments (0)