useScrollPosition Hook: Tracking Scroll State in React
Scroll position tracking is deceptively complex. Raw scroll events fire dozens of times per second, creating performance bottlenecks if not carefully managed. Components need to know whether the user is scrolling down or up, how far they've scrolled, and where they started—information that shouldn't leak into component logic.
The useScrollPosition hook abstracts this complexity, providing reliable scroll state without the boilerplate. Whether you're building sticky headers that hide on scroll down, infinite loaders that trigger at the bottom, or progress indicators that follow reading, this hook handles the performance-critical details.
Table of Contents
- Understanding Scroll Performance
- Basic useScrollPosition Implementation
- Advanced Tracking with Direction and Velocity
- Scroll Restoration and History Management
- Practical Application Scenarios
- Performance Optimization Techniques
- FAQ
Understanding Scroll Performance
Most developers encounter scroll event problems the same way:
// ❌ This causes jank: 60+ calls per second on scroll
window.addEventListener('scroll', () => {
const scrollY = window.scrollY;
// Triggers re-render, re-layout, expensive calculations
updateUI(scrollY);
});
This pattern creates a bottleneck. Every scroll pixel fires the handler, triggering renders that fight against the browser's frame rate. Large applications see frame drops, battery drain on mobile, and poor Core Web Vitals.
The solution requires:
- Debouncing/Throttling: Fire handlers less frequently
- Request animation frame: Sync with browser's repaint cycle
- State management: Avoid re-renders when scroll position hasn't meaningfully changed
- Scroll direction: Track whether user scrolled up or down
- Cleanup: Manage listeners carefully to prevent memory leaks
A solid useScrollPosition hook handles all of this.
Basic useScrollPosition Implementation
TypeScript Version
import { useEffect, useState, useRef } from 'react';
interface ScrollPosition {
x: number;
y: number;
isScrolling: boolean;
}
interface UseScrollPositionOptions {
// Debounce duration in milliseconds
debounceMs?: number;
// Use throttle instead of debounce
throttleMs?: number;
// Track multiple elements (window, document, custom refs)
elements?: (HTMLElement | Window)[];
}
export function useScrollPosition(
options: UseScrollPositionOptions = {}
): ScrollPosition {
const {
debounceMs = 150,
throttleMs,
elements = [window],
} = options;
const [position, setPosition] = useState<ScrollPosition>({
x: 0,
y: 0,
isScrolling: false,
});
const timeoutRef = useRef<NodeJS.Timeout>();
const isScrollingTimeoutRef = useRef<NodeJS.Timeout>();
const lastCallRef = useRef<number>(0);
useEffect(() => {
if (typeof window === 'undefined') return;
const handleScroll = () => {
const now = Date.now();
const delay = throttleMs || debounceMs;
// Clear previous timeout
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
// Clear "is scrolling" timeout
if (isScrollingTimeoutRef.current) {
clearTimeout(isScrollingTimeoutRef.current);
}
// For throttle: check elapsed time
if (throttleMs && now - lastCallRef.current < throttleMs) {
return;
}
lastCallRef.current = now;
// Update scroll position and set isScrolling to true
setPosition({
x: window.scrollX || window.pageXOffset,
y: window.scrollY || window.pageYOffset,
isScrolling: true,
});
// After delay, set isScrolling to false
isScrollingTimeoutRef.current = setTimeout(() => {
setPosition(prev => ({
...prev,
isScrolling: false,
}));
}, 1000); // User stopped scrolling after 1 second
};
// Attach listeners to all elements
elements.forEach(element => {
if (element && typeof element.addEventListener === 'function') {
element.addEventListener('scroll', handleScroll, { passive: true });
}
});
// Set initial position
if (typeof window !== 'undefined') {
setPosition({
x: window.scrollX || window.pageXOffset,
y: window.scrollY || window.pageYOffset,
isScrolling: false,
});
}
return () => {
// Cleanup
clearTimeout(timeoutRef.current);
clearTimeout(isScrollingTimeoutRef.current);
elements.forEach(element => {
if (element && typeof element.removeEventListener === 'function') {
element.removeEventListener('scroll', handleScroll);
}
});
};
}, [debounceMs, throttleMs, JSON.stringify(elements)]);
return position;
}
JavaScript Version
export function useScrollPosition(options = {}) {
const {
debounceMs = 150,
throttleMs,
elements = [window],
} = options;
const [position, setPosition] = useState({
x: 0,
y: 0,
isScrolling: false,
});
const timeoutRef = useRef();
const isScrollingTimeoutRef = useRef();
const lastCallRef = useRef(0);
useEffect(() => {
if (typeof window === 'undefined') return;
const handleScroll = () => {
const now = Date.now();
const delay = throttleMs || debounceMs;
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
if (isScrollingTimeoutRef.current) {
clearTimeout(isScrollingTimeoutRef.current);
}
if (throttleMs && now - lastCallRef.current < throttleMs) {
return;
}
lastCallRef.current = now;
setPosition({
x: window.scrollX || window.pageXOffset,
y: window.scrollY || window.pageYOffset,
isScrolling: true,
});
isScrollingTimeoutRef.current = setTimeout(() => {
setPosition(prev => ({
...prev,
isScrolling: false,
}));
}, 1000);
};
elements.forEach(element => {
if (element && typeof element.addEventListener === 'function') {
element.addEventListener('scroll', handleScroll, { passive: true });
}
});
if (typeof window !== 'undefined') {
setPosition({
x: window.scrollX || window.pageXOffset,
y: window.scrollY || window.pageYOffset,
isScrolling: false,
});
}
return () => {
clearTimeout(timeoutRef.current);
clearTimeout(isScrollingTimeoutRef.current);
elements.forEach(element => {
if (element && typeof element.removeEventListener === 'function') {
element.removeEventListener('scroll', handleScroll);
}
});
};
}, [debounceMs, throttleMs]);
return position;
}
Advanced Tracking with Direction and Velocity
Detecting Scroll Direction
A common pattern is showing/hiding headers based on scroll direction. This requires comparing previous and current positions:
interface ScrollState {
x: number;
y: number;
isScrolling: boolean;
direction: 'up' | 'down' | 'idle';
isAtTop: boolean;
isAtBottom: boolean;
progress: number; // 0 to 1: how far scrolled
}
export function useScrollState(): ScrollState {
const [state, setState] = useState<ScrollState>({
x: 0,
y: 0,
isScrolling: false,
direction: 'idle',
isAtTop: true,
isAtBottom: false,
progress: 0,
});
const previousYRef = useRef<number>(0);
const timeoutRef = useRef<NodeJS.Timeout>();
useEffect(() => {
if (typeof window === 'undefined') return;
const handleScroll = () => {
const scrollY = window.scrollY || window.pageYOffset;
const scrollHeight =
document.documentElement.scrollHeight - window.innerHeight;
// Determine direction
let direction: 'up' | 'down' | 'idle' = 'idle';
if (scrollY > previousYRef.current) {
direction = 'down';
} else if (scrollY < previousYRef.current) {
direction = 'up';
}
previousYRef.current = scrollY;
// Calculate progress (0 to 1)
const progress = scrollHeight > 0 ? scrollY / scrollHeight : 0;
setState({
x: window.scrollX || window.pageXOffset,
y: scrollY,
isScrolling: true,
direction,
isAtTop: scrollY < 50,
isAtBottom: scrollY + window.innerHeight >= scrollHeight - 50,
progress,
});
// Clear timeout
clearTimeout(timeoutRef.current);
// Set idle after 1 second
timeoutRef.current = setTimeout(() => {
setState(prev => ({
...prev,
direction: 'idle',
isScrolling: false,
}));
}, 1000);
};
window.addEventListener('scroll', handleScroll, { passive: true });
// Set initial state
const scrollHeight =
document.documentElement.scrollHeight - window.innerHeight;
const progress = scrollHeight > 0 ? window.scrollY / scrollHeight : 0;
setState({
x: window.scrollX || window.pageXOffset,
y: window.scrollY || window.pageYOffset,
isScrolling: false,
direction: 'idle',
isAtTop: true,
isAtBottom: false,
progress,
});
return () => {
clearTimeout(timeoutRef.current);
window.removeEventListener('scroll', handleScroll);
};
}, []);
return state;
}
JavaScript Version
export function useScrollState() {
const [state, setState] = useState({
x: 0,
y: 0,
isScrolling: false,
direction: 'idle',
isAtTop: true,
isAtBottom: false,
progress: 0,
});
const previousYRef = useRef(0);
const timeoutRef = useRef();
useEffect(() => {
if (typeof window === 'undefined') return;
const handleScroll = () => {
const scrollY = window.scrollY || window.pageYOffset;
const scrollHeight = document.documentElement.scrollHeight - window.innerHeight;
let direction = 'idle';
if (scrollY > previousYRef.current) {
direction = 'down';
} else if (scrollY < previousYRef.current) {
direction = 'up';
}
previousYRef.current = scrollY;
const progress = scrollHeight > 0 ? scrollY / scrollHeight : 0;
setState({
x: window.scrollX || window.pageXOffset,
y: scrollY,
isScrolling: true,
direction,
isAtTop: scrollY < 50,
isAtBottom: scrollY + window.innerHeight >= scrollHeight - 50,
progress,
});
clearTimeout(timeoutRef.current);
timeoutRef.current = setTimeout(() => {
setState(prev => ({
...prev,
direction: 'idle',
isScrolling: false,
}));
}, 1000);
};
window.addEventListener('scroll', handleScroll, { passive: true });
const scrollHeight = document.documentElement.scrollHeight - window.innerHeight;
const progress = scrollHeight > 0 ? window.scrollY / scrollHeight : 0;
setState({
x: window.scrollX || window.pageXOffset,
y: window.scrollY || window.pageYOffset,
isScrolling: false,
direction: 'idle',
isAtTop: true,
isAtBottom: false,
progress,
});
return () => {
clearTimeout(timeoutRef.current);
window.removeEventListener('scroll', handleScroll);
};
}, []);
return state;
}
Scroll Restoration and History Management
Saving and Restoring Scroll Position
When navigating between pages or modals, preserving scroll position improves UX:
const scrollPositionMap = new Map<string, number>();
export function useSaveScrollPosition(key: string) {
useEffect(() => {
// Save scroll position on unmount
return () => {
scrollPositionMap.set(key, window.scrollY || window.pageYOffset);
};
}, [key]);
}
export function useRestoreScrollPosition(key: string) {
useEffect(() => {
// Restore scroll position after component mounts
const savedPosition = scrollPositionMap.get(key);
if (savedPosition !== undefined) {
// Use setTimeout to ensure DOM is painted first
const timeoutId = setTimeout(() => {
window.scrollTo(0, savedPosition);
}, 0);
return () => clearTimeout(timeoutId);
}
}, [key]);
}
// Usage in a page component
export function BlogPost({ id }: { id: string }) {
const postKey = `post-${id}`;
useSaveScrollPosition(postKey);
useRestoreScrollPosition(postKey);
return (
<article>
{/* Content */}
</article>
);
}
JavaScript Version
const scrollPositionMap = new Map();
export function useSaveScrollPosition(key) {
useEffect(() => {
return () => {
scrollPositionMap.set(key, window.scrollY || window.pageYOffset);
};
}, [key]);
}
export function useRestoreScrollPosition(key) {
useEffect(() => {
const savedPosition = scrollPositionMap.get(key);
if (savedPosition !== undefined) {
const timeoutId = setTimeout(() => {
window.scrollTo(0, savedPosition);
}, 0);
return () => clearTimeout(timeoutId);
}
}, [key]);
}
Practical Application Scenarios
Scenario 1: Sticky Header That Hides on Scroll Down
A navigation header that automatically hides when scrolling down and shows when scrolling up:
export function StickyHeader() {
const { direction, isAtTop } = useScrollState();
const [isVisible, setIsVisible] = useState(true);
useEffect(() => {
if (isAtTop) {
setIsVisible(true);
} else if (direction === 'down') {
setIsVisible(false);
} else if (direction === 'up') {
setIsVisible(true);
}
}, [direction, isAtTop]);
return (
<header
style={{
position: 'sticky',
top: 0,
transform: isVisible ? 'translateY(0)' : 'translateY(-100%)',
transition: 'transform 0.3s ease-out',
zIndex: 1000,
}}
className="header"
>
<nav>Navigation items</nav>
</header>
);
}
Scenario 2: Infinite Scroll with Loading Detection
Trigger loading more items when scrolling near the bottom:
interface UseInfiniteScrollOptions {
threshold?: number; // pixels from bottom
onLoadMore: () => Promise<void>;
}
export function useInfiniteScroll({
threshold = 500,
onLoadMore,
}: UseInfiniteScrollOptions) {
const { isAtBottom } = useScrollState();
const [isLoading, setIsLoading] = useState(false);
const [hasMore, setHasMore] = useState(true);
const sentinelRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!isAtBottom || isLoading || !hasMore) return;
const loadMore = async () => {
setIsLoading(true);
try {
await onLoadMore();
} catch (error) {
console.error('Failed to load more items:', error);
} finally {
setIsLoading(false);
}
};
loadMore();
}, [isAtBottom, isLoading, hasMore, onLoadMore]);
return { isLoading, setHasMore, sentinelRef };
}
// Usage
function FeedPage() {
const [items, setItems] = useState([]);
const { isLoading, setHasMore } = useInfiniteScroll({
threshold: 800,
onLoadMore: async () => {
const newItems = await fetchItems(items.length);
if (newItems.length === 0) {
setHasMore(false);
} else {
setItems(prev => [...prev, ...newItems]);
}
},
});
return (
<div>
{items.map(item => (
<div key={item.id}>{item.title}</div>
))}
{isLoading && <div>Loading more...</div>}
</div>
);
}
Scenario 3: Reading Progress Bar
Show reading progress as user scrolls through long-form content:
export function ReadingProgress() {
const { progress } = useScrollState();
return (
<div
style={{
position: 'fixed',
top: 0,
left: 0,
height: '3px',
backgroundColor: '#3b82f6',
width: `${progress * 100}%`,
transition: 'width 0.1s linear',
zIndex: 999,
}}
/>
);
}
// Usage in article layout
export function ArticleLayout({ content }: { content: string }) {
return (
<>
<ReadingProgress />
<article className="prose">
{content}
</article>
</>
);
}
Scenario 4: Scroll-to-Top Button with Smart Visibility
Show a floating button only when user has scrolled past a threshold:
export function ScrollToTopButton() {
const { y, isScrolling } = useScrollPosition({ debounceMs: 100 });
const [isVisible, setIsVisible] = useState(false);
useEffect(() => {
// Show button when scrolled down more than 400px
setIsVisible(y > 400);
}, [y]);
const handleClick = () => {
window.scrollTo({
top: 0,
behavior: 'smooth',
});
};
return (
<button
onClick={handleClick}
style={{
position: 'fixed',
bottom: '2rem',
right: '2rem',
opacity: isVisible ? 1 : 0,
pointerEvents: isVisible ? 'auto' : 'none',
transform: isVisible ? 'scale(1)' : 'scale(0.8)',
transition: 'all 0.3s ease-out',
padding: '0.75rem 1rem',
backgroundColor: '#3b82f6',
color: 'white',
border: 'none',
borderRadius: '0.5rem',
cursor: 'pointer',
}}
>
↑ Back to top
</button>
);
}
Performance Optimization Techniques
Using RequestAnimationFrame for Smooth Updates
For animations tied to scroll, use RAF for 60fps consistency:
export function useScrollAnimation(callback: (y: number) => void) {
const rafRef = useRef<number>();
const scrollYRef = useRef(0);
useEffect(() => {
const handleScroll = () => {
scrollYRef.current = window.scrollY || window.pageYOffset;
// Cancel pending animation frame
if (rafRef.current) {
cancelAnimationFrame(rafRef.current);
}
// Schedule callback for next frame
rafRef.current = requestAnimationFrame(() => {
callback(scrollYRef.current);
});
};
window.addEventListener('scroll', handleScroll, { passive: true });
return () => {
window.removeEventListener('scroll', handleScroll);
if (rafRef.current) {
cancelAnimationFrame(rafRef.current);
}
};
}, [callback]);
}
// Usage: Parallax effect
export function ParallaxImage() {
const [translateY, setTranslateY] = useState(0);
useScrollAnimation((scrollY) => {
// Smooth parallax: 30% of scroll distance
setTranslateY(scrollY * 0.3);
});
return (
<img
src="parallax.jpg"
style={{
transform: `translateY(${translateY}px)`,
willChange: 'transform', // Hint browser for optimization
}}
/>
);
}
Caching Expensive Calculations
Avoid recalculating scroll height on every scroll:
export function useScrollMetrics() {
const [metrics, setMetrics] = useState(() => calculateMetrics());
const calculateMetrics = useCallback(() => {
return {
scrollHeight: document.documentElement.scrollHeight,
clientHeight: window.innerHeight,
maxScroll: document.documentElement.scrollHeight - window.innerHeight,
};
}, []);
useEffect(() => {
const handleResize = () => {
setMetrics(calculateMetrics());
};
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, [calculateMetrics]);
return metrics;
}
FAQ
Q: Why use window.scrollY and window.pageYOffset together?
A: For maximum browser compatibility. scrollY/scrollX are modern but not supported in older browsers. pageYOffset/pageXOffset are legacy but universally supported. Using both covers all cases:
const scrollY = window.scrollY || window.pageYOffset;
Q: Should I use throttle or debounce for scroll events?
A: It depends on use case:
- Throttle (e.g., 60 frames/second): For smooth animations, parallax, or anything tied to visual rendering
- Debounce (e.g., 150ms): For expensive operations like fetching data or analytics tracking
Most UI updates use throttle, expensive operations use debounce.
Q: How do I handle scroll events on scrollable divs (not the window)?
A: Pass the element via options:
const containerRef = useRef<HTMLDivElement>(null);
const { y } = useScrollPosition({ elements: [containerRef.current!] });
return (
<div ref={containerRef} style={{ overflow: 'auto', height: '500px' }}>
{/* Content */}
</div>
);
Q: Does scroll tracking affect Core Web Vitals?
A: No, when done correctly. The key is passive event listeners ({ passive: true }), which prevent blocking scroll performance. Debouncing/throttling ensures updates don't cause layout thrashing. This hook handles both, maintaining 60fps scroll.
Q: How do I scroll to a specific element smoothly?
A: Use the standard API:
const elementRef = useRef<HTMLDivElement>(null);
const scrollToElement = () => {
elementRef.current?.scrollIntoView({
behavior: 'smooth',
block: 'start',
});
};
return <div ref={elementRef}>Target element</div>;
Q: Can I use this with Next.js and SSR?
A: Yes, the hook checks typeof window === 'undefined' for safety. On the server, it returns default values. After hydration, it syncs to actual scroll position:
// Always safe for SSR
const { y } = useScrollPosition({ throttleMs: 100 });
Q: What's the performance impact of multiple scroll listeners?
A: Each useScrollPosition hook adds one event listener. Browser handles hundreds efficiently. However, in heavily scrolled UIs (ByteDance feeds, Alibaba dashboards), consider sharing a single listener via Context:
const ScrollContext = createContext<ScrollState | null>(null);
export function ScrollProvider({ children }: { children: ReactNode }) {
const scrollState = useScrollState();
return (
<ScrollContext.Provider value={scrollState}>
{children}
</ScrollContext.Provider>
);
}
// All children access same scroll data
export function useScroll() {
const context = useContext(ScrollContext);
if (!context) throw new Error('useScroll must be inside ScrollProvider');
return context;
}
Common Patterns
Pattern 1: Scroll-linked Animations
export function useScrollLinkedStyle(minScroll: number, maxScroll: number) {
const { y } = useScrollPosition({ throttleMs: 16 }); // ~60fps
const percentage = Math.min(
Math.max((y - minScroll) / (maxScroll - minScroll), 0),
1
);
return {
opacity: percentage,
transform: `scale(${0.8 + percentage * 0.2})`,
};
}
Pattern 2: Lazy Load Indicators
export function useLazyLoadTrigger(offset: number = 500) {
const { isAtBottom } = useScrollState();
// Trigger when within offset pixels of bottom
return isAtBottom && window.scrollY > document.body.scrollHeight - window.innerHeight - offset;
}
Related Articles
- useEffect Hook: Managing Side Effects
- useRef Hook: Accessing DOM Directly
- Scroll Performance Optimization
- Intersection Observer Pattern
Next Steps
The useScrollPosition hook becomes more powerful when combined with:
- Intersection Observer API for element visibility (more performant than scroll tracking for specific elements)
- Framer Motion for scroll-driven animations
- Virtualization libraries for rendering only visible items
- Analytics tracking tied to scroll depth
Start with the basic hook for building sticky headers and infinite scroll. As your application scales, consider optimizing with shared scroll context and specialized hooks for different use cases.
What scroll interactions are you building? Share your implementations in the comments—scroll-linked animations, reading progress, and custom loading indicators are always interesting edge cases to discuss.
Google AdSense Placeholder
CONTENT SLOT