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
JavaScript
;
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