UNPKG

smooth-auto-scroll

Version:

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

420 lines (417 loc) 12.8 kB
"use strict"; var __create = Object.create; var __defProp = Object.defineProperty; var __getOwnPropDesc = Object.getOwnPropertyDescriptor; var __getOwnPropNames = Object.getOwnPropertyNames; var __getProtoOf = Object.getPrototypeOf; var __hasOwnProp = Object.prototype.hasOwnProperty; var __export = (target, all) => { for (var name in all) __defProp(target, name, { get: all[name], enumerable: true }); }; var __copyProps = (to, from, except, desc) => { if (from && typeof from === "object" || typeof from === "function") { for (let key of __getOwnPropNames(from)) if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); } return to; }; var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps( // If the importer is in node compatibility mode or this is not an ESM // file that has been converted to a CommonJS file using a Babel- // compatible transform (i.e. "__esModule" has not been set), then set // "default" to the CommonJS "module.exports" for node compatibility. isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target, mod )); var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); // src/index.ts var index_exports = {}; __export(index_exports, { AutoScrollContainer: () => AutoScrollContainer, useSmoothAutoScroll: () => useSmoothAutoScroll }); module.exports = __toCommonJS(index_exports); // src/useSmoothAutoScroll.ts var import_react = require("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 = (0, import_react.useRef)(null); const lastTsRef = (0, import_react.useRef)(null); const accumRef = (0, import_react.useRef)(0); const pausedRef = (0, import_react.useRef)(false); const startTimeRef = (0, import_react.useRef)(null); const currentVelocityRef = (0, import_react.useRef)(0); const targetVelocityRef = (0, import_react.useRef)(pxPerSecond); const currentDirectionRef = (0, import_react.useRef)(direction); const hasStartedRef = (0, import_react.useRef)(false); const resumeTimeoutRef = (0, import_react.useRef)(null); const reducedMotionRef = (0, import_react.useRef)(false); const resetVisualShift = (0, import_react.useCallback)(() => { const inner = innerRef.current; if (inner) inner.style.transform = ""; }, [innerRef]); (0, import_react.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]); (0, import_react.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 ]); (0, import_react.useEffect)(() => { targetVelocityRef.current = pxPerSecond; }, [pxPerSecond]); (0, import_react.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 var import_react2 = __toESM(require("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 = (0, import_react2.useRef)(null); const innerRef = (0, import_react2.useRef)(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 }); (0, import_react2.useEffect)(() => { 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__ */ import_react2.default.createElement("div", { ref: outerRef, className, style, ...restProps }, /* @__PURE__ */ import_react2.default.createElement("div", { ref: innerRef }, children)); }; // Annotate the CommonJS export names for ESM import in node: 0 && (module.exports = { AutoScrollContainer, useSmoothAutoScroll }); //# sourceMappingURL=index.cjs.map