import { useCallback, useEffect, useRef } from 'react'; /** * Options for the auto-scroll hook. */interface UseAutoScrollBottomOptions { /** * Threshold in pixels from bottom to consider "at bottom". * Default: 100px (generous threshold for better UX) */ threshold?: number; /** * Smooth scroll duration in milliseconds. * Default: 300ms */ smoothDuration?: number; /** * Whether auto-scroll is enabled. * Default: true */ enabled?: boolean; /** * Scroll behavior used for automatic follow when content updates. * Default: 'smooth' */ autoBehavior?: ScrollBehavior; /** * Whether auto-scroll is temporarily disabled (e.g., during navigation). * Unlike enabled, this is for transient disabling during specific operations. * Default: false */ disabled?: boolean; /** * Optional external scroll container ref. If provided, the hook will use this * ref instead of creating its own. Useful when the ref needs to be shared * with other hooks (e.g., navigation coordinator). */ externalRef?: React.RefObject<HTMLDivElement>; /** * When this value changes, reset isAtBottom state to true. * Use for tab/session changes to ensure new content scrolls to bottom. */ resetKey?: string | null;} /** * Return type for the auto-scroll hook. */interface UseAutoScrollBottomReturn { /** * Ref to attach to the scroll container element. */ scrollContainerRef: React.RefObject<HTMLDivElement>; /** * Get whether the user is currently at the bottom of the scroll container. * Returns a function to avoid accessing ref.current during render. */ getIsAtBottom: () => boolean; /** * Manually scroll to bottom with smooth animation. */ scrollToBottom: (behavior?: ScrollBehavior) => void; /** * Check and update the isAtBottom state. * Call this after content changes if needed. */ checkIsAtBottom: () => boolean;} export function isNearBottom( scrollTop: number, scrollHeight: number, clientHeight: number, threshold: number): boolean { const distanceFromBottom = scrollHeight - scrollTop - clientHeight; return distanceFromBottom <= threshold;} /** * Custom hook for managing auto-scroll-to-bottom behavior in chat-like interfaces. * * Features: * - Tracks whether user is at the bottom of the scroll container * - Automatically scrolls to bottom when content changes (if user was at bottom) * - Smooth scrolling animation * - Respects user's scroll position (doesn't force scroll if user scrolled up) * * @param dependencies - Array of dependencies that trigger scroll check (e.g., conversation items) * @param options - Configuration options * @returns Scroll management utilities * * @example * ```tsx * const { scrollContainerRef, isAtBottom, scrollToBottom } = useAutoScrollBottom( * [conversation?.items.length], * { threshold: 100 } * ); * * return ( * <div ref={scrollContainerRef} className="overflow-y-auto"> * {items.map(renderItem)} * </div> * ); * ``` */export function useAutoScrollBottom( dependencies: unknown[], options: UseAutoScrollBottomOptions = {}): UseAutoScrollBottomReturn { const { threshold = 100, smoothDuration = 300, enabled = true, autoBehavior = 'smooth', disabled = false, externalRef, resetKey, } = options; // Use external ref if provided, otherwise create our own const internalRef = useRef<HTMLDivElement>(null); const scrollContainerRef = externalRef ?? internalRef; const isAtBottomRef = useRef(true); // Start assuming at bottom const wasAtBottomBeforeUpdateRef = useRef(true); const isScrollingRef = useRef(false); // Track disabled state in ref for checking inside RAF callbacks const disabledRef = useRef(disabled); // Track resetKey to detect changes const prevResetKeyRef = useRef(resetKey); /** * Check if the scroll container is at the bottom. */ const checkIsAtBottom = useCallback((): boolean => { const container = scrollContainerRef.current; if (!container) return true; const { scrollTop, scrollHeight, clientHeight } = container; return isNearBottom(scrollTop, scrollHeight, clientHeight, threshold); // eslint-disable-next-line react-hooks/exhaustive-deps -- scrollContainerRef is a ref, stable across renders }, [threshold]); /** * Scroll to bottom with smooth animation. */ const scrollToBottom = useCallback( (behavior: ScrollBehavior = 'smooth') => { const container = scrollContainerRef.current; if (!container) return; // Prevent scroll event handler from updating isAtBottom during programmatic scroll isScrollingRef.current = true; const targetScrollTop = container.scrollHeight - container.clientHeight; if (behavior === 'smooth') { // Use native smooth scrolling container.scrollTo({ top: targetScrollTop, behavior: 'smooth', }); // Reset flag after animation completes setTimeout(() => { isScrollingRef.current = false; isAtBottomRef.current = true; }, smoothDuration); } else { container.scrollTop = targetScrollTop; isScrollingRef.current = false; isAtBottomRef.current = true; } }, // eslint-disable-next-line react-hooks/exhaustive-deps -- scrollContainerRef is a ref, stable across renders [smoothDuration] ); /** * Handle scroll events to track isAtBottom state. */ const handleScroll = useCallback(() => { // Ignore scroll events during programmatic scrolling if (isScrollingRef.current) return; isAtBottomRef.current = checkIsAtBottom(); }, [checkIsAtBottom]); /** * Set up scroll event listener. */ useEffect(() => { const container = scrollContainerRef.current; if (!container) return; container.addEventListener('scroll', handleScroll, { passive: true }); return () => { container.removeEventListener('scroll', handleScroll); }; // eslint-disable-next-line react-hooks/exhaustive-deps -- scrollContainerRef is a ref, stable across renders }, [handleScroll]); /** * Before content updates, remember if we were at bottom. */ useEffect(() => { wasAtBottomBeforeUpdateRef.current = isAtBottomRef.current; }); // Keep disabledRef in sync with disabled prop useEffect(() => { disabledRef.current = disabled; }, [disabled]); // Reset isAtBottom state when resetKey changes (e.g., tab/session switch) // This ensures new content will auto-scroll to bottom useEffect(() => { if (resetKey !== prevResetKeyRef.current) { isAtBottomRef.current = true; wasAtBottomBeforeUpdateRef.current = true; prevResetKeyRef.current = resetKey; } }, [resetKey]); /** * After content updates (dependencies change), scroll to bottom if we were at bottom. */ useEffect(() => { // Skip if disabled (e.g., during navigation) or not enabled if (!enabled || disabled) return; // Use requestAnimationFrame to ensure DOM has updated requestAnimationFrame(() => { // Re-check disabled state inside RAF - it might have changed between effect and callback // This prevents auto-scroll from firing if navigation started after the effect ran if (disabledRef.current) return; // Only auto-scroll if user was at bottom before the update if (wasAtBottomBeforeUpdateRef.current) { scrollToBottom(autoBehavior); } }); // eslint-disable-next-line react-hooks/exhaustive-deps -- Dynamic dependencies array is intentional design }, [...dependencies, enabled, disabled, autoBehavior, scrollToBottom]); /** * Getter function for isAtBottom to avoid accessing ref.current during render. */ const getIsAtBottom = useCallback((): boolean => { return isAtBottomRef.current; }, []); return { scrollContainerRef, getIsAtBottom, scrollToBottom, checkIsAtBottom, };}
import { useCallback, useEffect, useRef } from 'react';
/**
* Options for the auto-scroll hook.
*/
interface UseAutoScrollBottomOptions {
/**
* Threshold in pixels from bottom to consider "at bottom".
* Default: 100px (generous threshold for better UX)
*/
threshold?: number;
/**
* Smooth scroll duration in milliseconds.
* Default: 300ms
*/
smoothDuration?: number;
/**
* Whether auto-scroll is enabled.
* Default: true
*/
enabled?: boolean;
/**
* Scroll behavior used for automatic follow when content updates.
* Default: 'smooth'
*/
autoBehavior?: ScrollBehavior;
/**
* Whether auto-scroll is temporarily disabled (e.g., during navigation).
* Unlike enabled, this is for transient disabling during specific operations.
* Default: false
*/
disabled?: boolean;
/**
* Optional external scroll container ref. If provided, the hook will use this
* ref instead of creating its own. Useful when the ref needs to be shared
* with other hooks (e.g., navigation coordinator).
*/
externalRef?: React.RefObject<HTMLDivElement>;
/**
* When this value changes, reset isAtBottom state to true.
* Use for tab/session changes to ensure new content scrolls to bottom.
*/
resetKey?: string | null;
}
/**
* Return type for the auto-scroll hook.
*/
interface UseAutoScrollBottomReturn {
/**
* Ref to attach to the scroll container element.
*/
scrollContainerRef: React.RefObject<HTMLDivElement>;
/**
* Get whether the user is currently at the bottom of the scroll container.
* Returns a function to avoid accessing ref.current during render.
*/
getIsAtBottom: () => boolean;
/**
* Manually scroll to bottom with smooth animation.
*/
scrollToBottom: (behavior?: ScrollBehavior) => void;
/**
* Check and update the isAtBottom state.
* Call this after content changes if needed.
*/
checkIsAtBottom: () => boolean;
}
export function isNearBottom(
scrollTop: number,
scrollHeight: number,
clientHeight: number,
threshold: number
): boolean {
const distanceFromBottom = scrollHeight - scrollTop - clientHeight;
return distanceFromBottom <= threshold;
}
/**
* Custom hook for managing auto-scroll-to-bottom behavior in chat-like interfaces.
*
* Features:
* - Tracks whether user is at the bottom of the scroll container
* - Automatically scrolls to bottom when content changes (if user was at bottom)
* - Smooth scrolling animation
* - Respects user's scroll position (doesn't force scroll if user scrolled up)
*
* @param dependencies - Array of dependencies that trigger scroll check (e.g., conversation items)
* @param options - Configuration options
* @returns Scroll management utilities
*
* @example
* ```tsx
* const { scrollContainerRef, isAtBottom, scrollToBottom } = useAutoScrollBottom(
* [conversation?.items.length],
* { threshold: 100 }
* );
*
* return (
* <div ref={scrollContainerRef} className="overflow-y-auto">
* {items.map(renderItem)}
* </div>
* );
* ```
*/
export function useAutoScrollBottom(
dependencies: unknown[],
options: UseAutoScrollBottomOptions = {}
): UseAutoScrollBottomReturn {
const {
threshold = 100,
smoothDuration = 300,
enabled = true,
autoBehavior = 'smooth',
disabled = false,
externalRef,
resetKey,
} = options;
// Use external ref if provided, otherwise create our own
const internalRef = useRef<HTMLDivElement>(null);
const scrollContainerRef = externalRef ?? internalRef;
const isAtBottomRef = useRef(true); // Start assuming at bottom
const wasAtBottomBeforeUpdateRef = useRef(true);
const isScrollingRef = useRef(false);
// Track disabled state in ref for checking inside RAF callbacks
const disabledRef = useRef(disabled);
// Track resetKey to detect changes
const prevResetKeyRef = useRef(resetKey);
/**
* Check if the scroll container is at the bottom.
*/
const checkIsAtBottom = useCallback((): boolean => {
const container = scrollContainerRef.current;
if (!container) return true;
const { scrollTop, scrollHeight, clientHeight } = container;
return isNearBottom(scrollTop, scrollHeight, clientHeight, threshold);
// eslint-disable-next-line react-hooks/exhaustive-deps -- scrollContainerRef is a ref, stable across renders
}, [threshold]);
/**
* Scroll to bottom with smooth animation.
*/
const scrollToBottom = useCallback(
(behavior: ScrollBehavior = 'smooth') => {
const container = scrollContainerRef.current;
if (!container) return;
// Prevent scroll event handler from updating isAtBottom during programmatic scroll
isScrollingRef.current = true;
const targetScrollTop = container.scrollHeight - container.clientHeight;
if (behavior === 'smooth') {
// Use native smooth scrolling
container.scrollTo({
top: targetScrollTop,
behavior: 'smooth',
});
// Reset flag after animation completes
setTimeout(() => {
isScrollingRef.current = false;
isAtBottomRef.current = true;
}, smoothDuration);
} else {
container.scrollTop = targetScrollTop;
isScrollingRef.current = false;
isAtBottomRef.current = true;
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps -- scrollContainerRef is a ref, stable across renders
[smoothDuration]
);
/**
* Handle scroll events to track isAtBottom state.
*/
const handleScroll = useCallback(() => {
// Ignore scroll events during programmatic scrolling
if (isScrollingRef.current) return;
isAtBottomRef.current = checkIsAtBottom();
}, [checkIsAtBottom]);
/**
* Set up scroll event listener.
*/
useEffect(() => {
const container = scrollContainerRef.current;
if (!container) return;
container.addEventListener('scroll', handleScroll, { passive: true });
return () => {
container.removeEventListener('scroll', handleScroll);
};
// eslint-disable-next-line react-hooks/exhaustive-deps -- scrollContainerRef is a ref, stable across renders
}, [handleScroll]);
/**
* Before content updates, remember if we were at bottom.
*/
useEffect(() => {
wasAtBottomBeforeUpdateRef.current = isAtBottomRef.current;
});
// Keep disabledRef in sync with disabled prop
useEffect(() => {
disabledRef.current = disabled;
}, [disabled]);
// Reset isAtBottom state when resetKey changes (e.g., tab/session switch)
// This ensures new content will auto-scroll to bottom
useEffect(() => {
if (resetKey !== prevResetKeyRef.current) {
isAtBottomRef.current = true;
wasAtBottomBeforeUpdateRef.current = true;
prevResetKeyRef.current = resetKey;
}
}, [resetKey]);
/**
* After content updates (dependencies change), scroll to bottom if we were at bottom.
*/
useEffect(() => {
// Skip if disabled (e.g., during navigation) or not enabled
if (!enabled || disabled) return;
// Use requestAnimationFrame to ensure DOM has updated
requestAnimationFrame(() => {
// Re-check disabled state inside RAF - it might have changed between effect and callback
// This prevents auto-scroll from firing if navigation started after the effect ran
if (disabledRef.current) return;
// Only auto-scroll if user was at bottom before the update
if (wasAtBottomBeforeUpdateRef.current) {
scrollToBottom(autoBehavior);
}
});
// eslint-disable-next-line react-hooks/exhaustive-deps -- Dynamic dependencies array is intentional design
}, [...dependencies, enabled, disabled, autoBehavior, scrollToBottom]);
/**
* Getter function for isAtBottom to avoid accessing ref.current during render.
*/
const getIsAtBottom = useCallback((): boolean => {
return isAtBottomRef.current;
}, []);
return {
scrollContainerRef,
getIsAtBottom,
scrollToBottom,
checkIsAtBottom,
};
}