Tuesday, October 14, 2025

Autoscrolling with respect for the user

I couldn't find an implementation of this that didn't have serious flaws, so here's a reliable React hook for ensuring a container scrolls to the bottom as its content grows, except when the user scrolls back to look at something. Returns a `ref` for the container, a `tailRef` for a component at the end of the container's children, and an `autoScroll` function that can be called to manually trigger the autoscroll behavior. Accepts a user scroll callback in case you want to respond to user scrolling events.

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

// Automatically scroll to the bottom of the container when the size or
// scrolling content changes, unless the user has started scrolling.
// Set onUserScroll to false to always scroll to bottom on component size
// changes, regardless of user activity.
export const useAutoScroll = (onUserScroll = null) => {
  const [container, setContainer] = useState(null);
  const ref = useCallback(node => setContainer(node), []);
  const tailRef = useRef();
  const isUserScrolling = useRef();
  const isAutoScrolling = useRef();
  const lastScrollHeight = useRef();
  const atBottomScrollTop = useRef();
  const isAtBottom = el => Math.abs(el.scrollTop + el.clientHeight - el.scrollHeight) < 1;

  const scrollToBottom = (onComplete = null) => {
    if (!container || isAtBottom(container)) {
      return;
    }
    isAutoScrolling.current = true;
    container.classList.add('auto-scrolling');

    // Check if any ancestor container is user-scrolling or auto-scrolling
    // If so, we need to avoid scrollIntoView which would scroll the ancestor
    const hasScrollingAncestor = container.parentElement?.closest('.user-scrolling, .auto-scrolling') !== null;

    // Use scrollIntoView only if no ancestor is scrolling (nice smooth scroll)
    // Otherwise use scrollTop to avoid triggering ancestor scroll and causing loops
    if (hasScrollingAncestor) {
      // Only scroll this container, don't trigger ancestor scrolling
      container.scrollTop = container.scrollHeight;
      // Immediately mark as complete since scrollTop is synchronous
      atBottomScrollTop.current = container.scrollTop;
      isAutoScrolling.current = false;
      container.classList.remove('auto-scrolling');
      onComplete && onComplete(container);
    } else {
      tailRef.current?.scrollIntoView({ behavior: "smooth" });

      // Check to see if we're at the bottom yet
      setTimeout(() => {
        if (container) {
          if (!isAtBottom(container)) {
            scrollToBottom(onComplete);
          } else {
            atBottomScrollTop.current = container.scrollTop;
            isAutoScrolling.current = false;
            container.classList.remove('auto-scrolling');
            onComplete && onComplete(container);
          }
        }
      }, 50);
    }
  };

  const autoScroll = (force = false, onComplete = null) => {
    if (!isUserScrolling.current || force) {
      scrollToBottom(onComplete);
    }
    else {
      onComplete && onComplete(container);
    }
  };

  const scrollHandler = e => {
    if (e.target !== container) {
      return;
    }
    const hasScrollbar = container.scrollHeight > container.clientHeight;
    lastScrollHeight.current = container.scrollHeight;
    if (atBottomScrollTop.current === null) {
      atBottomScrollTop.current = container.scrollHeight - container.clientHeight;
    }
    const currentScrollTop = container.scrollTop;

    // Clear user scrolling if at bottom or no scrollbar
    if (isAtBottom(container) || !hasScrollbar) {
      atBottomScrollTop.current = currentScrollTop;
      if (isUserScrolling.current) {
        isUserScrolling.current = false;
        container.classList.remove('user-scrolling');
      }
    }
    // Detect user scrolling up
    else if (!isAutoScrolling.current
             && atBottomScrollTop.current != null
             && atBottomScrollTop.current > currentScrollTop) {
      if (!isUserScrolling.current) {
        isUserScrolling.current = true;
        container.classList.add('user-scrolling');
        onUserScroll && onUserScroll(e);
      }
    }
  };

  // Ensure we trigger autoscroll on component content/size change
  useEffect(() => {
    if (container) {
      lastScrollHeight.current = container.scrollHeight;
      container.addEventListener("scroll", scrollHandler);

      const mutationObserver = new MutationObserver((mutations) => {
        // If content was cleared or significantly reduced, reset user scrolling flag
        const contentCleared = container.scrollHeight <= container.clientHeight;
        if (contentCleared) {
          isUserScrolling.current = false;
          container.classList.remove('user-scrolling');
        }

        autoScroll();
      });
      mutationObserver.observe(container, {childList: true, subtree: true, characterData: true});

      let resizeTimeout;
      const resizeObserver = new ResizeObserver((entries) => {
        // Debounce to avoid "ResizeObserver loop completed with undelivered notifications"
        clearTimeout(resizeTimeout);
        resizeTimeout = setTimeout(() => {
          // Clear user scrolling if scrollbar disappears
          const hasScrollbar = container.scrollHeight > container.clientHeight;
          if (!hasScrollbar && isUserScrolling.current) {
            isUserScrolling.current = false;
            container.classList.remove('user-scrolling');
          }

          autoScroll();
        }, 0);
      });
      Array.from(container.children).forEach(child => resizeObserver.observe(child));
      resizeObserver.observe(container);
      // Initialize state
      autoScroll();

      return () => {
        container.removeEventListener("scroll", scrollHandler);
        mutationObserver.disconnect();
        resizeObserver.disconnect();
      };
    }
  }, [container, onUserScroll]);

  return [ref, tailRef, autoScroll];
};