react-sway
Version:
A React component for smooth infinite scrolling, designed for creating engaging, continuous content streams with minimal configuration.
354 lines (351 loc) • 13.2 kB
JavaScript
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
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 __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
// src/index.ts
var index_exports = {};
__export(index_exports, {
ReactSway: () => ReactSway_default
});
module.exports = __toCommonJS(index_exports);
// src/ReactSway.tsx
var import_react = require("react");
var import_jsx_runtime = require("react/jsx-runtime");
var SCROLL_SPEED = 0.5;
var INACTIVITY_DELAY = 2e3;
var FRICTION = 0.95;
var MAX_DELTA_TIME = 3;
function ReactSway({ children }) {
const [position, setPosition] = (0, import_react.useState)(0);
const [, setVelocity] = (0, import_react.useState)(0);
const [isDragging, setIsDragging] = (0, import_react.useState)(false);
const [isPaused, setIsPaused] = (0, import_react.useState)(false);
const [autoScrollEnabled, setAutoScrollEnabled] = (0, import_react.useState)(true);
const [isTabActive, setIsTabActive] = (0, import_react.useState)(true);
const [contentHeight, setContentHeight] = (0, import_react.useState)(0);
const [loopPoint, setLoopPoint] = (0, import_react.useState)(0);
const [, setContainerHeight] = (0, import_react.useState)(0);
const containerRef = (0, import_react.useRef)(null);
const animationFrameRef = (0, import_react.useRef)(null);
const inactivityTimerRef = (0, import_react.useRef)(null);
const lastTouchYRef = (0, import_react.useRef)(0);
const lastMouseYRef = (0, import_react.useRef)(0);
const lastFrameTimeRef = (0, import_react.useRef)(0);
let visualPosition = position % (loopPoint || 1);
if (visualPosition > 0 && loopPoint > 0) visualPosition -= loopPoint;
(0, import_react.useEffect)(() => {
const calculateDimensions = () => {
if (containerRef.current) {
containerRef.current.offsetHeight;
const currentContentHeight = containerRef.current.scrollHeight;
const calculatedLoopPoint = currentContentHeight / 3;
if (currentContentHeight > 0) {
setContentHeight(currentContentHeight);
setLoopPoint(calculatedLoopPoint);
}
setContainerHeight(window.innerHeight);
}
};
const rafId = requestAnimationFrame(() => {
calculateDimensions();
});
return () => {
cancelAnimationFrame(rafId);
};
}, [children]);
const pauseAutoScroll = (0, import_react.useCallback)(() => {
setAutoScrollEnabled(false);
if (inactivityTimerRef.current) {
clearTimeout(inactivityTimerRef.current);
}
}, []);
const scheduleAutoScrollResume = (0, import_react.useCallback)(() => {
if (inactivityTimerRef.current) {
clearTimeout(inactivityTimerRef.current);
}
inactivityTimerRef.current = setTimeout(() => {
setAutoScrollEnabled(true);
}, INACTIVITY_DELAY);
}, []);
const handleMouseDown = (0, import_react.useCallback)((e) => {
e.preventDefault();
setIsDragging(true);
lastMouseYRef.current = e.clientY;
setVelocity(0);
pauseAutoScroll();
}, [pauseAutoScroll]);
const handleMouseMove = (0, import_react.useCallback)((e) => {
if (!isDragging) return;
e.preventDefault();
const deltaY = e.clientY - lastMouseYRef.current;
setPosition((prev) => prev + deltaY);
setVelocity(deltaY);
lastMouseYRef.current = e.clientY;
}, [isDragging]);
const handleMouseUp = (0, import_react.useCallback)((e) => {
if (!isDragging) return;
e.preventDefault();
setIsDragging(false);
scheduleAutoScrollResume();
}, [isDragging, scheduleAutoScrollResume]);
const handleTouchStart = (0, import_react.useCallback)((e) => {
if (e.touches.length === 1) {
setIsDragging(true);
lastTouchYRef.current = e.touches[0].clientY;
setVelocity(0);
pauseAutoScroll();
}
}, [pauseAutoScroll]);
const handleTouchMove = (0, import_react.useCallback)((e) => {
if (!isDragging || e.touches.length !== 1) return;
e.preventDefault();
const touch = e.touches[0];
const deltaY = touch.clientY - lastTouchYRef.current;
setPosition((prev) => prev + deltaY);
setVelocity(deltaY);
lastTouchYRef.current = touch.clientY;
}, [isDragging]);
const handleTouchEnd = (0, import_react.useCallback)((_e) => {
if (!isDragging) return;
setIsDragging(false);
scheduleAutoScrollResume();
}, [isDragging, scheduleAutoScrollResume]);
const handleWheel = (0, import_react.useCallback)((e) => {
e.preventDefault();
setVelocity((prev) => prev - e.deltaY * 0.3);
pauseAutoScroll();
scheduleAutoScrollResume();
}, [pauseAutoScroll, scheduleAutoScrollResume]);
const togglePause = (0, import_react.useCallback)(() => {
setIsPaused((prev) => {
const newPausedState = !prev;
if (newPausedState) {
pauseAutoScroll();
} else {
setAutoScrollEnabled(true);
}
return newPausedState;
});
}, [pauseAutoScroll]);
const handleKeyDown = (0, import_react.useCallback)((e) => {
switch (e.key) {
case " ":
e.preventDefault();
togglePause();
break;
case "ArrowUp":
e.preventDefault();
setVelocity((prev) => prev + 15);
pauseAutoScroll();
scheduleAutoScrollResume();
break;
case "ArrowDown":
e.preventDefault();
setVelocity((prev) => prev - 15);
pauseAutoScroll();
scheduleAutoScrollResume();
break;
case "Home":
e.preventDefault();
setPosition(0);
setVelocity(0);
pauseAutoScroll();
scheduleAutoScrollResume();
break;
case "End":
e.preventDefault();
if (loopPoint > 0) {
setPosition(-loopPoint);
}
setVelocity(0);
pauseAutoScroll();
scheduleAutoScrollResume();
break;
default:
break;
}
}, [togglePause, pauseAutoScroll, scheduleAutoScrollResume, loopPoint]);
const handleResize = (0, import_react.useCallback)(() => {
setContainerHeight(window.innerHeight);
if (containerRef.current) {
const currentContentHeight = containerRef.current.scrollHeight;
setContentHeight(currentContentHeight);
setLoopPoint(currentContentHeight / 3);
}
}, []);
(0, import_react.useEffect)(() => {
const currentContainer = containerRef.current;
if (!currentContainer) return;
const boundHandlers = {
mouseDown: handleMouseDown,
mouseMove: handleMouseMove,
mouseUp: handleMouseUp,
touchStart: handleTouchStart,
touchMove: handleTouchMove,
touchEnd: handleTouchEnd,
wheel: handleWheel
};
currentContainer.addEventListener("mousedown", boundHandlers.mouseDown);
window.addEventListener("mousemove", boundHandlers.mouseMove);
window.addEventListener("mouseup", boundHandlers.mouseUp);
currentContainer.addEventListener("touchstart", boundHandlers.touchStart, { passive: true });
window.addEventListener("touchmove", boundHandlers.touchMove, { passive: false });
window.addEventListener("touchend", boundHandlers.touchEnd, { passive: true });
currentContainer.addEventListener("wheel", boundHandlers.wheel, { passive: false });
return () => {
currentContainer.removeEventListener("mousedown", boundHandlers.mouseDown);
window.removeEventListener("mousemove", boundHandlers.mouseMove);
window.removeEventListener("mouseup", boundHandlers.mouseUp);
currentContainer.removeEventListener("touchstart", boundHandlers.touchStart);
window.removeEventListener("touchmove", boundHandlers.touchMove);
window.removeEventListener("touchend", boundHandlers.touchEnd);
currentContainer.removeEventListener("wheel", boundHandlers.wheel);
};
}, [handleMouseDown, handleMouseMove, handleMouseUp, handleTouchStart, handleTouchMove, handleTouchEnd, handleWheel]);
(0, import_react.useEffect)(() => {
document.addEventListener("keydown", handleKeyDown);
window.addEventListener("resize", handleResize);
return () => {
document.removeEventListener("keydown", handleKeyDown);
window.removeEventListener("resize", handleResize);
};
}, [handleKeyDown, handleResize]);
(0, import_react.useEffect)(() => {
const handleVisibilityChange = () => {
setIsTabActive(!document.hidden);
if (!document.hidden) {
lastFrameTimeRef.current = performance.now();
}
};
document.addEventListener("visibilitychange", handleVisibilityChange);
return () => {
document.removeEventListener("visibilitychange", handleVisibilityChange);
};
}, []);
(0, import_react.useEffect)(() => {
if (!isTabActive || isPaused) {
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current);
animationFrameRef.current = null;
}
return;
}
const animate = (currentTime) => {
let deltaTime = lastFrameTimeRef.current ? (currentTime - lastFrameTimeRef.current) / 16.667 : 1;
deltaTime = Math.min(deltaTime, MAX_DELTA_TIME);
lastFrameTimeRef.current = currentTime;
setPosition((prevPosition) => {
let newPosition = prevPosition;
if (autoScrollEnabled && !isDragging && !isPaused) {
newPosition -= SCROLL_SPEED * deltaTime;
}
return newPosition;
});
setVelocity((prevVelocity) => {
let newVelocity = prevVelocity;
if (Math.abs(newVelocity) > 0.1) {
if (!isDragging) {
setPosition((prev) => prev + newVelocity * deltaTime);
}
newVelocity *= Math.pow(FRICTION, deltaTime);
} else {
newVelocity = 0;
}
return newVelocity;
});
setPosition((prevPosition) => {
let newPosition = prevPosition;
if (loopPoint > 0) {
while (newPosition > 0) {
newPosition -= loopPoint;
}
while (newPosition < -loopPoint * 2) {
newPosition += loopPoint;
}
}
return newPosition;
});
animationFrameRef.current = requestAnimationFrame(animate);
};
animationFrameRef.current = requestAnimationFrame(animate);
return () => {
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current);
}
};
}, [isTabActive, autoScrollEnabled, isDragging, isPaused, loopPoint]);
(0, import_react.useEffect)(() => {
if (!containerRef.current) return;
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
entry.target.classList.add("visible");
}
});
},
{
root: null,
rootMargin: "100px",
threshold: 0.01
}
);
const items = containerRef.current.querySelectorAll(".content-item");
items.forEach((item) => observer.observe(item));
return () => {
items.forEach((item) => observer.unobserve(item));
observer.disconnect();
};
}, [children, contentHeight]);
return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
"div",
{
className: "react-sway-container scroller-content",
ref: containerRef,
style: {
transform: `translate3d(0, ${visualPosition}px, 0)`,
cursor: isDragging ? "grabbing" : "grab",
position: "absolute",
width: "100%",
willChange: "transform",
WebkitTransform: "translateZ(0)",
touchAction: "none",
userSelect: "none",
WebkitUserSelect: "none",
msUserSelect: "none",
MozUserSelect: "none",
overscrollBehavior: "contain",
overflow: "hidden",
// Prevent component's content from overflowing and causing page scroll
// Ensure it's on top and can receive events
pointerEvents: "auto",
zIndex: 1
},
tabIndex: 0,
children: [
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "content-group original", children }),
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("aside", { className: "content-group duplicate", "aria-hidden": "true", "data-duplicate": "true", role: "presentation", children }),
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("aside", { className: "content-group duplicate", "aria-hidden": "true", "data-duplicate": "true", role: "presentation", children })
]
}
);
}
var ReactSway_default = ReactSway;
// Annotate the CommonJS export names for ESM import in node:
0 && (module.exports = {
ReactSway
});
;