UNPKG

smooth-auto-scroll

Version:

Smooth auto-scroll React hook and component for ultra-smooth motion at any speed.

382 lines (381 loc) 10.8 kB
// src/useSmoothAutoScroll.ts import { useEffect, useRef, useCallback } from "react"; function useSmoothAutoScroll({ enabled = true, containerRef, innerRef, pxPerSecond, bottomTolerance = 1, topTolerance = 1, capDtMs = 16.67, // ~60fps for smoother updates pauseEvents = ["wheel", "touchmove", "keydown", "mousedown", "focus"], resumeEvents = ["mouseleave", "touchend", "touchcancel"], smoothingFactor = 0.1, // For smooth velocity changes accelerationTime = 1e3, // 1 second to reach full speed direction = "down", resumeDelay = 0, pauseOnHover = false, pauseOnFocus = false, startOffset = 0, endOffset = 0, respectReducedMotion = true, // Callbacks onStart, onPause, onResume, onReachEnd, onReachTop }) { const rafIdRef = useRef(null); const lastTsRef = useRef(null); const accumRef = useRef(0); const pausedRef = useRef(false); const startTimeRef = useRef(null); const currentVelocityRef = useRef(0); const targetVelocityRef = useRef(pxPerSecond); const currentDirectionRef = useRef(direction); const hasStartedRef = useRef(false); const resumeTimeoutRef = useRef(null); const reducedMotionRef = useRef(false); const resetVisualShift = useCallback(() => { const inner = innerRef.current; if (inner) inner.style.transform = ""; }, [innerRef]); useEffect(() => { if (respectReducedMotion && typeof window !== "undefined") { const mediaQuery = window.matchMedia("(prefers-reduced-motion: reduce)"); reducedMotionRef.current = mediaQuery.matches; const handleChange = (e) => { reducedMotionRef.current = e.matches; }; mediaQuery.addEventListener("change", handleChange); return () => mediaQuery.removeEventListener("change", handleChange); } }, [respectReducedMotion]); useEffect(() => { if (!enabled) return; lastTsRef.current = null; accumRef.current = 0; pausedRef.current = false; startTimeRef.current = null; currentVelocityRef.current = 0; targetVelocityRef.current = pxPerSecond; currentDirectionRef.current = direction; hasStartedRef.current = false; resetVisualShift(); if (resumeTimeoutRef.current) { clearTimeout(resumeTimeoutRef.current); resumeTimeoutRef.current = null; } const step = (ts) => { const el = containerRef.current; const inner = innerRef.current; if (!el || !inner) { rafIdRef.current = requestAnimationFrame(step); return; } if (lastTsRef.current == null) { lastTsRef.current = ts; startTimeRef.current = ts; } const dt = Math.min(ts - (lastTsRef.current ?? ts), capDtMs); lastTsRef.current = ts; if (respectReducedMotion && reducedMotionRef.current) { rafIdRef.current = requestAnimationFrame(step); return; } const currentDirection = currentDirectionRef.current; const isScrollingDown = currentDirection === "down"; if (!pausedRef.current) { if (!hasStartedRef.current) { hasStartedRef.current = true; onStart?.(); } const baseVelocity = pxPerSecond; let targetVelocity; if (accelerationTime <= 0) { targetVelocity = baseVelocity * (isScrollingDown ? 1 : -1); } else { const elapsedTime = ts - (startTimeRef.current ?? ts); const accelerationProgress = Math.min( elapsedTime / accelerationTime, 1 ); const easedProgress = 1 - Math.pow(1 - accelerationProgress, 3); targetVelocity = baseVelocity * easedProgress * (isScrollingDown ? 1 : -1); } currentVelocityRef.current += (targetVelocity - currentVelocityRef.current) * smoothingFactor; const deltaPixels = currentVelocityRef.current * dt / 1e3; accumRef.current += deltaPixels; const intDelta = Math.floor(Math.abs(accumRef.current)); const frac = Math.abs(accumRef.current) - intDelta; if (intDelta > 0) { if (isScrollingDown) { el.scrollTop += intDelta; } else { el.scrollTop -= intDelta; } accumRef.current = accumRef.current >= 0 ? frac : -frac; } const fracOffset = isScrollingDown ? -frac : frac; inner.style.transform = `translate3d(0, ${fracOffset}px, 0)`; const scrollTop = el.scrollTop; const scrollHeight = el.scrollHeight; const clientHeight = el.clientHeight; const atBottom = scrollTop + clientHeight >= scrollHeight - bottomTolerance - endOffset; const atTop = scrollTop <= topTolerance + startOffset; if (isScrollingDown && atBottom) { onReachEnd?.(); resetVisualShift(); } else if (!isScrollingDown && atTop) { onReachTop?.(); resetVisualShift(); } } rafIdRef.current = requestAnimationFrame(step); }; rafIdRef.current = requestAnimationFrame(step); return () => { if (rafIdRef.current != null) cancelAnimationFrame(rafIdRef.current); resetVisualShift(); }; }, [ enabled, containerRef, innerRef, pxPerSecond, bottomTolerance, topTolerance, capDtMs, smoothingFactor, accelerationTime, direction, startOffset, endOffset, respectReducedMotion, onStart, onReachEnd, onReachTop, resetVisualShift ]); useEffect(() => { targetVelocityRef.current = pxPerSecond; }, [pxPerSecond]); useEffect(() => { const el = containerRef.current; if (!el) return; const onUser = (_event) => { if (!pausedRef.current) { onPause?.(); } pausedRef.current = true; resetVisualShift(); accumRef.current = 0; currentVelocityRef.current = 0; if (resumeTimeoutRef.current) { clearTimeout(resumeTimeoutRef.current); resumeTimeoutRef.current = null; } }; const onScroll = () => { }; const onResumeEvent = (_event) => { if (resumeDelay > 0) { resumeTimeoutRef.current = setTimeout(() => { if (pausedRef.current) { onResume?.(); } pausedRef.current = false; lastTsRef.current = null; startTimeRef.current = null; }, resumeDelay); } else { if (pausedRef.current) { onResume?.(); } pausedRef.current = false; lastTsRef.current = null; startTimeRef.current = null; } }; const onHover = (event) => { if (pauseOnHover) { onUser(event); } }; const onFocus = (event) => { if (pauseOnFocus) { onUser(event); } }; for (const evt of pauseEvents) { el.addEventListener(evt, onUser, { passive: true }); } for (const evt of resumeEvents) { el.addEventListener(evt, onResumeEvent, { passive: true }); } el.addEventListener("scroll", onScroll, { passive: true }); if (pauseOnHover) { el.addEventListener("mouseenter", onHover); } if (pauseOnFocus) { el.addEventListener("focus", onFocus, { passive: true }); } const onVis = () => { if (document.hidden) { pausedRef.current = true; } else { pausedRef.current = false; lastTsRef.current = null; startTimeRef.current = null; } }; document.addEventListener("visibilitychange", onVis); return () => { for (const evt of pauseEvents) { el.removeEventListener(evt, onUser); } for (const evt of resumeEvents) { el.removeEventListener(evt, onResumeEvent); } el.removeEventListener("scroll", onScroll); if (pauseOnHover) { el.removeEventListener("mouseenter", onHover); } if (pauseOnFocus) { el.removeEventListener("focus", onFocus); } document.removeEventListener("visibilitychange", onVis); if (resumeTimeoutRef.current) { clearTimeout(resumeTimeoutRef.current); } }; }, [ containerRef, pauseEvents, resumeEvents, bottomTolerance, topTolerance, endOffset, startOffset, resumeDelay, pauseOnHover, pauseOnFocus, onPause, onResume, resetVisualShift ]); return { pause: () => { if (!pausedRef.current) { onPause?.(); } pausedRef.current = true; resetVisualShift(); }, resume: () => { if (pausedRef.current) { onResume?.(); } pausedRef.current = false; lastTsRef.current = null; startTimeRef.current = null; }, reset: () => { accumRef.current = 0; hasStartedRef.current = false; currentDirectionRef.current = direction; resetVisualShift(); }, get paused() { return pausedRef.current; }, get currentDirection() { return currentDirectionRef.current; }, get hasStarted() { return hasStartedRef.current; } }; } // src/AutoScrollContainer.tsx import React, { useEffect as useEffect2, useRef as useRef2 } from "react"; var AutoScrollContainer = ({ containerRef, enabled = true, pxPerSecond, bottomTolerance, topTolerance, capDtMs, smoothingFactor, accelerationTime, pauseEvents, resumeEvents, direction, resumeDelay, pauseOnHover, pauseOnFocus, startOffset, endOffset, respectReducedMotion, // Callbacks onStart, onPause, onResume, onReachEnd, onReachTop, className, style, children, ...restProps }) => { const outerRef = useRef2(null); const innerRef = useRef2(null); const scrollOwnerRef = containerRef ?? outerRef; useSmoothAutoScroll({ enabled, containerRef: scrollOwnerRef, innerRef, pxPerSecond, bottomTolerance, topTolerance, capDtMs, smoothingFactor, accelerationTime, pauseEvents, resumeEvents, direction, resumeDelay, pauseOnHover, pauseOnFocus, startOffset, endOffset, respectReducedMotion, // Callbacks onStart, onPause, onResume, onReachEnd, onReachTop }); useEffect2(() => { if (!containerRef && outerRef.current) { outerRef.current.style.overflowY = "auto"; outerRef.current.style.willChange = "transform"; } if (innerRef.current) { innerRef.current.style.willChange = "transform"; innerRef.current.style.backfaceVisibility = "hidden"; innerRef.current.style.perspective = "1000px"; } }, [containerRef]); return /* @__PURE__ */ React.createElement("div", { ref: outerRef, className, style, ...restProps }, /* @__PURE__ */ React.createElement("div", { ref: innerRef }, children)); }; export { AutoScrollContainer, useSmoothAutoScroll }; //# sourceMappingURL=index.mjs.map