AdSense Leaderboard

Google AdSense Placeholder

HEADER SLOT

useScrollPosition Hook: Tracking Scroll State in React

Last updated:
useScrollPosition Hook: Tracking Scroll State in React

Master scroll tracking in React with useScrollPosition. Build sticky headers, infinite scroll, progress bars, and scroll restoration with production-ready code examples.

# 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

  1. Understanding Scroll Performance
  2. Basic useScrollPosition Implementation
  3. Advanced Tracking with Direction and Velocity
  4. Scroll Restoration and History Management
  5. Practical Application Scenarios
  6. Performance Optimization Techniques
  7. FAQ

# Understanding Scroll Performance

Most developers encounter scroll event problems the same way:

javascript
// ❌ 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:

  1. Debouncing/Throttling: Fire handlers less frequently
  2. Request animation frame: Sync with browser's repaint cycle
  3. State management: Avoid re-renders when scroll position hasn't meaningfully changed
  4. Scroll direction: Track whether user scrolled up or down
  5. Cleanup: Manage listeners carefully to prevent memory leaks

A solid useScrollPosition hook handles all of this.

# Basic useScrollPosition Implementation

# TypeScript Version

typescript
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

javascript
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:

typescript
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

javascript
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:

typescript
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

javascript
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:

typescript
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:

typescript
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:

typescript
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:

typescript
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:

typescript
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:

typescript
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:

typescript
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:

typescript
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:

typescript
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:

typescript
// 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:

typescript
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

typescript
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

typescript
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;
}


# 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.

Sponsored Content

Google AdSense Placeholder

CONTENT SLOT

Sponsored

Google AdSense Placeholder

FOOTER SLOT