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];
};
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.
Subscribe to:
Post Comments (Atom)
No comments:
Post a Comment