react-sway 0.3.1 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,35 +1,85 @@
1
- import { useCallback, useEffect, useMemo, useRef, useState } from "react";
1
+ import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from "react";
2
2
  import { jsx, jsxs } from "react/jsx-runtime";
3
- //#region src/ReactSway.tsx
3
+ //#region src/edge-hover-velocity.ts
4
4
  /**
5
- * Core ReactSway component implementing axis-aware infinite scrolling interactions.
5
+ * Defines the bounded spatial mapping from edge-hover depth to Sway velocity.
6
6
  */
7
- /** Velocity applied per arrow key press (pixels). */
8
- var ARROW_KEY_VELOCITY = 15;
9
7
  /** Default edge-hover activation thickness in pixels. */
10
8
  var DEFAULT_EDGE_HOVER_SIZE = 96;
11
- /** Default friction coefficient applied to velocity each frame. */
12
- var DEFAULT_FRICTION = .95;
13
- /** Default IntersectionObserver rootMargin for lazy visibility detection. */
14
- var DEFAULT_LAZY_ROOT_MARGIN = "100px";
15
- /** Default IntersectionObserver threshold for lazy visibility detection. */
16
- var DEFAULT_LAZY_THRESHOLD = .01;
17
- /** Default delay in milliseconds before auto-scroll resumes after user interaction. */
18
- var DEFAULT_RESUME_DELAY = 2e3;
19
- /** Default scroll speed in pixels per frame at 60fps. */
20
- var DEFAULT_SPEED = .5;
21
- /** Number of stacked content groups used to build the seamless loop. */
22
- var MIN_LOOP_SEGMENTS = 3;
23
- /** Maximum deltaTime cap to prevent physics instability during frame drops. */
24
- var MAX_DELTA_TIME = 3;
25
- /** Maximum allowed velocity magnitude to prevent runaway scrolling. */
26
- var MAX_VELOCITY = 150;
27
- /** Duration of a single frame at 60fps in milliseconds. */
28
- var MS_PER_FRAME_60FPS = 16.667;
29
- /** Speed multiplier when user prefers reduced motion (25% of normal). */
30
- var REDUCED_MOTION_SPEED_FACTOR = .25;
31
- /** Debounce delay in milliseconds for ResizeObserver callbacks. */
32
- var RESIZE_DEBOUNCE_MS = 150;
9
+ /** Default maximum edge-hover speed relative to the configured base speed. */
10
+ var DEFAULT_EDGE_HOVER_SPEED_MULTIPLIER = 6;
11
+ /** Upper bound for consumer-configured edge-hover speed amplification. */
12
+ var MAX_EDGE_HOVER_SPEED_MULTIPLIER = 10;
13
+ /** Normalized ingress band used to remove the zero-to-baseline velocity jump. */
14
+ var EDGE_HOVER_ENTRY_FEATHER_INTENSITY = .08;
15
+ /** Intensity reached at the physical edge of the visible viewport. */
16
+ var EDGE_HOVER_PHYSICAL_EDGE_INTENSITY = .5;
17
+ /** Historical physical-edge response retained before beyond-edge acceleration. */
18
+ var EDGE_HOVER_PHYSICAL_EDGE_SPEED_MULTIPLIER = 3;
19
+ function clampUnit(value) {
20
+ if (!Number.isFinite(value)) return 0;
21
+ return Math.max(0, Math.min(1, value));
22
+ }
23
+ function smoothUnitStep(value) {
24
+ const unitValue = clampUnit(value);
25
+ return unitValue * unitValue * (3 - 2 * unitValue);
26
+ }
27
+ function normalizeEdgeHoverIntensity(value) {
28
+ if (!Number.isFinite(value)) return 0;
29
+ const clampedValue = Math.max(-1, Math.min(1, value));
30
+ return clampedValue === 0 ? 0 : clampedValue;
31
+ }
32
+ function normalizeEdgeHoverSpeedMultiplier(value) {
33
+ if (!Number.isFinite(value)) return 6;
34
+ return Math.max(1, Math.min(MAX_EDGE_HOVER_SPEED_MULTIPLIER, value));
35
+ }
36
+ /**
37
+ * Maps signed edge depth onto a bounded velocity scale.
38
+ *
39
+ * Intensity carries direction and lies in [-1, 1]. The speed multiplier is
40
+ * the maximum amplification reached one activation-zone width beyond the
41
+ * visible edge.
42
+ */
43
+ function getEdgeHoverVelocityScale(intensity, speedMultiplier = 6) {
44
+ const normalizedIntensity = normalizeEdgeHoverIntensity(intensity);
45
+ if (normalizedIntensity === 0) return 0;
46
+ const direction = Math.sign(normalizedIntensity);
47
+ const depth = Math.abs(normalizedIntensity);
48
+ const maximumScale = normalizeEdgeHoverSpeedMultiplier(speedMultiplier);
49
+ if (depth < EDGE_HOVER_ENTRY_FEATHER_INTENSITY) return direction * smoothUnitStep(depth / EDGE_HOVER_ENTRY_FEATHER_INTENSITY);
50
+ const physicalEdgeScale = Math.min(EDGE_HOVER_PHYSICAL_EDGE_SPEED_MULTIPLIER, maximumScale);
51
+ if (depth <= EDGE_HOVER_PHYSICAL_EDGE_INTENSITY) {
52
+ const physicalEdgeProgress = (depth - EDGE_HOVER_ENTRY_FEATHER_INTENSITY) / (EDGE_HOVER_PHYSICAL_EDGE_INTENSITY - EDGE_HOVER_ENTRY_FEATHER_INTENSITY);
53
+ return direction * (1 + (physicalEdgeScale - 1) * smoothUnitStep(physicalEdgeProgress));
54
+ }
55
+ const beyondEdgeProgress = (depth - EDGE_HOVER_PHYSICAL_EDGE_INTENSITY) / (1 - EDGE_HOVER_PHYSICAL_EDGE_INTENSITY);
56
+ return direction * (physicalEdgeScale + (maximumScale - physicalEdgeScale) * smoothUnitStep(beyondEdgeProgress));
57
+ }
58
+ /**
59
+ * Computes signed edge depth using a symmetric linear ramp.
60
+ *
61
+ * The inner edge of either activation zone maps to zero, the physical edge
62
+ * maps to half intensity, and motion keeps accelerating outside the viewport
63
+ * until it reaches the cap one activation-zone width beyond the visible edge.
64
+ */
65
+ function getEdgeHoverIntensity(pointerPosition, visibleInterval, edgeHoverSize) {
66
+ const effectiveEdgeHoverSize = Math.min(Math.max(edgeHoverSize, 0), visibleInterval.size / 2);
67
+ if (effectiveEdgeHoverSize <= 0) return 0;
68
+ const accelerationDistance = effectiveEdgeHoverSize * 2;
69
+ const leadingEdgeEnd = visibleInterval.start + effectiveEdgeHoverSize;
70
+ if (pointerPosition <= leadingEdgeEnd) {
71
+ const leadingIntensity = clampUnit((leadingEdgeEnd - pointerPosition) / accelerationDistance);
72
+ if (leadingIntensity > 0) return leadingIntensity;
73
+ }
74
+ const trailingEdgeStart = visibleInterval.end - effectiveEdgeHoverSize;
75
+ if (pointerPosition >= trailingEdgeStart) {
76
+ const trailingIntensity = clampUnit((pointerPosition - trailingEdgeStart) / accelerationDistance);
77
+ if (trailingIntensity > 0) return -trailingIntensity;
78
+ }
79
+ return 0;
80
+ }
81
+ //#endregion
82
+ //#region src/wheel-input.ts
33
83
  /** Fallback pixel height for wheel events reported in line units. */
34
84
  var WHEEL_LINE_HEIGHT_FALLBACK_PX = 16;
35
85
  /** WheelEvent deltaMode value for line-based deltas. */
@@ -38,24 +88,15 @@ var WHEEL_MODE_LINE = 1;
38
88
  var WHEEL_MODE_PAGE = 2;
39
89
  /** Minimum pixel impulse for discrete wheel hardware reporting tiny pixel deltas. */
40
90
  var WHEEL_PIXEL_MIN_DELTA_PX = 48;
41
- /** Multiplier applied to wheel deltaY to convert to scroll velocity. */
42
- var WHEEL_VELOCITY_MULTIPLIER = .14;
43
- /** Small immediate wheel movement used to keep input responsive without jumping. */
44
- var WHEEL_IMMEDIATE_DELTA_FACTOR = .14;
45
91
  /** Legacy wheelDelta magnitude that usually represents one physical wheel notch. */
46
92
  var WHEEL_LEGACY_NOTCH_DELTA = 120;
47
- /** Minimum touch movement before a gesture is classified by axis. */
48
- var TOUCH_AXIS_LOCK_THRESHOLD_PX = 8;
49
93
  function getLegacyWheelPixelDeltaY(event) {
50
94
  const { wheelDelta, wheelDeltaY } = event;
51
95
  const legacyWheelDelta = typeof wheelDeltaY === "number" ? wheelDeltaY : wheelDelta;
52
96
  if (typeof legacyWheelDelta !== "number" || !Number.isFinite(legacyWheelDelta)) return 0;
53
97
  return -(legacyWheelDelta / WHEEL_LEGACY_NOTCH_DELTA) * WHEEL_PIXEL_MIN_DELTA_PX;
54
98
  }
55
- /**
56
- * Converts wheel deltas to pixels so mouse wheels, touchpads, and page-wheel
57
- * devices feed the same Sway velocity system.
58
- */
99
+ /** Converts line, page, modern pixel, and legacy wheel deltas to pixels. */
59
100
  function normalizeWheelDelta(event, container, axis) {
60
101
  const rawDelta = axis === "x" ? event.deltaX : event.deltaY;
61
102
  if (event.deltaMode === WHEEL_MODE_LINE) {
@@ -68,6 +109,10 @@ function normalizeWheelDelta(event, container, axis) {
68
109
  if (Math.abs(rawDelta) < WHEEL_PIXEL_MIN_DELTA_PX && Math.abs(legacyWheelPixelDeltaY) >= WHEEL_PIXEL_MIN_DELTA_PX) return legacyWheelPixelDeltaY;
69
110
  return rawDelta;
70
111
  }
112
+ /**
113
+ * Returns Sway's signed pixel delta, or null when native scroll chaining owns
114
+ * the gesture. This partial function is the wheel-ownership boundary.
115
+ */
71
116
  function getOwnedWheelDelta(event, container, isHorizontal, wheelMode) {
72
117
  const deltaX = normalizeWheelDelta(event, container, "x");
73
118
  const deltaY = normalizeWheelDelta(event, container, "y");
@@ -80,6 +125,41 @@ function getOwnedWheelDelta(event, container, isHorizontal, wheelMode) {
80
125
  if (wheelMode === "axis" && absDeltaX > absDeltaY) return null;
81
126
  return deltaY;
82
127
  }
128
+ //#endregion
129
+ //#region src/ReactSway.tsx
130
+ /**
131
+ * Core ReactSway component implementing axis-aware infinite scrolling interactions.
132
+ */
133
+ /** Velocity applied per arrow key press (pixels). */
134
+ var ARROW_KEY_VELOCITY = 15;
135
+ /** Default friction coefficient applied to velocity each frame. */
136
+ var DEFAULT_FRICTION = .95;
137
+ /** Default IntersectionObserver rootMargin for lazy visibility detection. */
138
+ var DEFAULT_LAZY_ROOT_MARGIN = "100px";
139
+ /** Default IntersectionObserver threshold for lazy visibility detection. */
140
+ var DEFAULT_LAZY_THRESHOLD = .01;
141
+ /** Default delay in milliseconds before auto-scroll resumes after user interaction. */
142
+ var DEFAULT_RESUME_DELAY = 2e3;
143
+ /** Default scroll speed in pixels per frame at 60fps. */
144
+ var DEFAULT_SPEED = .5;
145
+ /** Minimum stacked content groups needed to cover a seamless loop phase. */
146
+ var MIN_LOOP_SEGMENTS = 2;
147
+ /** Maximum deltaTime cap to prevent physics instability during frame drops. */
148
+ var MAX_DELTA_TIME = 3;
149
+ /** Edge-hover catch-up cap; larger jumps read as stalls followed by snapping. */
150
+ var MAX_EDGE_HOVER_DELTA_TIME = 1;
151
+ /** Maximum allowed velocity magnitude to prevent runaway scrolling. */
152
+ var MAX_VELOCITY = 150;
153
+ /** Duration of a single frame at 60fps in milliseconds. */
154
+ var MS_PER_FRAME_60FPS = 16.667;
155
+ /** Speed multiplier when user prefers reduced motion (25% of normal). */
156
+ var REDUCED_MOTION_SPEED_FACTOR = .25;
157
+ /** Multiplier applied to wheel deltaY to convert to scroll velocity. */
158
+ var WHEEL_VELOCITY_MULTIPLIER = .14;
159
+ /** Small immediate wheel movement used to keep input responsive without jumping. */
160
+ var WHEEL_IMMEDIATE_DELTA_FACTOR = .14;
161
+ /** Minimum touch movement before a gesture is classified by axis. */
162
+ var TOUCH_AXIS_LOCK_THRESHOLD_PX = 8;
83
163
  function getVisibleEdgeInterval(rect, isHorizontal) {
84
164
  const rectEnd = isHorizontal ? rect.right : rect.bottom;
85
165
  const rectStart = isHorizontal ? rect.left : rect.top;
@@ -113,10 +193,12 @@ function getVisibleEdgeInterval(rect, isHorizontal) {
113
193
  * </ReactSway>
114
194
  * ```
115
195
  */
116
- function ReactSway({ axis, autoScroll = true, children, direction = "up", draggable = true, edgeHoverScroll = false, edgeHoverSize = DEFAULT_EDGE_HOVER_SIZE, friction = DEFAULT_FRICTION, keyboard = true, lazy = true, lazyRootMargin = DEFAULT_LAZY_ROOT_MARGIN, lazyThreshold = DEFAULT_LAZY_THRESHOLD, onPause, onResume, onScroll, pauseOnInteraction = true, resumeDelay = DEFAULT_RESUME_DELAY, speed = DEFAULT_SPEED, wheelEnabled = true, wheelMode = "axis" }) {
196
+ var ReactSway = forwardRef(function ReactSway({ ariaLabel = "Scrollable content", axis, autoScroll = true, children, className, direction = "up", draggable = true, edgeHoverInputMode = "hybrid", edgeHoverScroll = false, edgeHoverSize = 96, edgeHoverSpeedMultiplier = 6, friction = DEFAULT_FRICTION, keyboard = true, lazy = true, lazyRootMargin = DEFAULT_LAZY_ROOT_MARGIN, lazyThreshold = DEFAULT_LAZY_THRESHOLD, onPause, onResume, onScroll, pauseOnInteraction = true, resumeDelay = DEFAULT_RESUME_DELAY, speed = DEFAULT_SPEED, wheelEnabled = true, wheelMode = "axis" }, ref) {
117
197
  const isHorizontal = useMemo(() => axis ?? (direction === "left" || direction === "right" ? "horizontal" : "vertical"), [axis, direction]) === "horizontal";
118
- const normalizedEdgeHoverSize = useMemo(() => Number.isFinite(edgeHoverSize) ? Math.max(1, edgeHoverSize) : DEFAULT_EDGE_HOVER_SIZE, [edgeHoverSize]);
198
+ const normalizedEdgeHoverSize = useMemo(() => Number.isFinite(edgeHoverSize) ? Math.max(1, edgeHoverSize) : 96, [edgeHoverSize]);
199
+ const normalizedEdgeHoverSpeedMultiplier = useMemo(() => normalizeEdgeHoverSpeedMultiplier(edgeHoverSpeedMultiplier), [edgeHoverSpeedMultiplier]);
119
200
  const normalizedFriction = useMemo(() => Number.isFinite(friction) ? Math.min(Math.max(friction, 0), 1) : DEFAULT_FRICTION, [friction]);
201
+ const normalizedLazyThreshold = useMemo(() => Number.isFinite(lazyThreshold) ? Math.min(Math.max(lazyThreshold, 0), 1) : DEFAULT_LAZY_THRESHOLD, [lazyThreshold]);
120
202
  const normalizedResumeDelay = useMemo(() => Number.isFinite(resumeDelay) ? Math.max(0, resumeDelay) : DEFAULT_RESUME_DELAY, [resumeDelay]);
121
203
  const normalizedSpeed = useMemo(() => Number.isFinite(speed) ? Math.max(0, speed) : DEFAULT_SPEED, [speed]);
122
204
  const touchAction = useMemo(() => {
@@ -124,16 +206,15 @@ function ReactSway({ axis, autoScroll = true, children, direction = "up", dragga
124
206
  return isHorizontal ? "pan-y" : "pan-x";
125
207
  }, [draggable, isHorizontal]);
126
208
  const overscrollBehavior = wheelEnabled && wheelMode === "capture" ? "contain" : "auto";
127
- const [isDragging, setIsDragging] = useState(false);
128
209
  const [isPaused, setIsPaused] = useState(false);
129
- const [isTabActive, setIsTabActive] = useState(true);
130
- const [loopPoint, setLoopPoint] = useState(0);
210
+ const [isTabActive, setIsTabActive] = useState(() => typeof document === "undefined" || !document.hidden);
131
211
  const [loopSegmentCount, setLoopSegmentCount] = useState(MIN_LOOP_SEGMENTS);
132
212
  const [prefersReducedMotion, setPrefersReducedMotion] = useState(() => {
133
213
  if (typeof window === "undefined") return false;
134
214
  return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
135
215
  });
136
216
  const animationFrameRef = useRef(null);
217
+ const animationLoopRef = useRef(null);
137
218
  const autoScrollRef = useRef({
138
219
  active: autoScroll,
139
220
  desired: autoScroll
@@ -148,13 +229,19 @@ function ReactSway({ axis, autoScroll = true, children, direction = "up", dragga
148
229
  const lastTouchXRef = useRef(0);
149
230
  const lastTouchYRef = useRef(0);
150
231
  const loopPointRef = useRef(0);
151
- const edgeHoverDirectionRef = useRef(0);
232
+ const edgeHoverIntervalCacheFrameRef = useRef(null);
233
+ const edgeHoverIntervalCacheRef = useRef(null);
234
+ const edgeHoverIntensityRef = useRef(0);
235
+ const externalEdgeHoverLeaseRef = useRef(false);
152
236
  const onPauseRef = useRef(onPause);
153
237
  const onResumeRef = useRef(onResume);
154
238
  const onScrollRef = useRef(onScroll);
155
239
  const originalGroupRef = useRef(null);
156
240
  const positionRef = useRef(0);
157
- const resizeDebounceTimerRef = useRef(null);
241
+ const loopMeasurementFrameRef = useRef(null);
242
+ const lastLoopMeasurementRef = useRef(null);
243
+ const mouseDragListenerCleanupRef = useRef(null);
244
+ const touchDragListenerCleanupRef = useRef(null);
158
245
  const touchInteractionStateRef = useRef("idle");
159
246
  const touchStartXRef = useRef(0);
160
247
  const touchStartYRef = useRef(0);
@@ -184,6 +271,32 @@ function ReactSway({ axis, autoScroll = true, children, direction = "up", dragga
184
271
  clearTimeout(inactivityTimerRef.current);
185
272
  inactivityTimerRef.current = null;
186
273
  }, []);
274
+ const startAnimationLoop = useCallback(() => {
275
+ if (animationFrameRef.current !== null || !animationLoopRef.current) return;
276
+ animationFrameRef.current = requestAnimationFrame(animationLoopRef.current);
277
+ }, []);
278
+ const clearEdgeHoverIntervalCache = useCallback(() => {
279
+ edgeHoverIntervalCacheRef.current = null;
280
+ if (edgeHoverIntervalCacheFrameRef.current !== null) {
281
+ cancelAnimationFrame(edgeHoverIntervalCacheFrameRef.current);
282
+ edgeHoverIntervalCacheFrameRef.current = null;
283
+ }
284
+ }, []);
285
+ const readVisibleEdgeInterval = useCallback((viewport) => {
286
+ const cachedInterval = edgeHoverIntervalCacheRef.current;
287
+ if (cachedInterval && cachedInterval.viewport === viewport && cachedInterval.isHorizontal === isHorizontal) return cachedInterval.interval;
288
+ const interval = getVisibleEdgeInterval(viewport.getBoundingClientRect(), isHorizontal);
289
+ edgeHoverIntervalCacheRef.current = {
290
+ interval,
291
+ isHorizontal,
292
+ viewport
293
+ };
294
+ if (edgeHoverIntervalCacheFrameRef.current === null) edgeHoverIntervalCacheFrameRef.current = requestAnimationFrame(() => {
295
+ edgeHoverIntervalCacheFrameRef.current = null;
296
+ edgeHoverIntervalCacheRef.current = null;
297
+ });
298
+ return interval;
299
+ }, [isHorizontal]);
187
300
  const renderPosition = useCallback((rawPosition) => {
188
301
  if (!containerRef.current) return;
189
302
  const currentLoopPoint = loopPointRef.current;
@@ -200,22 +313,33 @@ function ReactSway({ axis, autoScroll = true, children, direction = "up", dragga
200
313
  const wrapPosition = useCallback((rawPosition) => {
201
314
  const currentLoopPoint = loopPointRef.current;
202
315
  if (currentLoopPoint <= 0) return rawPosition;
203
- let wrappedPosition = rawPosition;
204
- while (wrappedPosition > 0) wrappedPosition -= currentLoopPoint;
205
- while (wrappedPosition < -currentLoopPoint * 2) wrappedPosition += currentLoopPoint;
316
+ if (rawPosition <= 0 && rawPosition >= -currentLoopPoint * 2) return rawPosition;
317
+ let wrappedPosition = rawPosition % currentLoopPoint;
318
+ if (wrappedPosition > 0) wrappedPosition -= currentLoopPoint;
206
319
  return wrappedPosition;
207
320
  }, []);
321
+ const setDraggingState = useCallback((nextIsDragging) => {
322
+ isDraggingRef.current = nextIsDragging;
323
+ if (containerRef.current && draggable) containerRef.current.style.cursor = nextIsDragging ? "grabbing" : "grab";
324
+ }, [draggable]);
208
325
  const recalculateLoopPoint = useCallback(() => {
209
326
  if (!containerRef.current || !originalGroupRef.current) return;
210
327
  const originalContentSize = isHorizontal ? originalGroupRef.current.scrollWidth : originalGroupRef.current.scrollHeight;
211
328
  if (originalContentSize <= 0) return;
212
329
  const viewport = containerRef.current.parentElement ?? containerRef.current;
213
330
  const viewportSize = isHorizontal ? Math.max(viewport.clientWidth, 1) : Math.max(viewport.clientHeight, 1);
214
- const nextLoopSegmentCount = Math.max(MIN_LOOP_SEGMENTS, Math.ceil(viewportSize / originalContentSize) + 2);
331
+ const nextLoopSegmentCount = Math.max(MIN_LOOP_SEGMENTS, Math.ceil(viewportSize / originalContentSize) + 1);
215
332
  const nextLoopPoint = originalContentSize;
333
+ const previousMeasurement = lastLoopMeasurementRef.current;
334
+ const nextMeasurement = {
335
+ contentSize: originalContentSize,
336
+ isHorizontal,
337
+ viewportSize
338
+ };
339
+ if (previousMeasurement?.contentSize === nextMeasurement.contentSize && previousMeasurement.isHorizontal === nextMeasurement.isHorizontal && previousMeasurement.viewportSize === nextMeasurement.viewportSize) return;
340
+ lastLoopMeasurementRef.current = nextMeasurement;
216
341
  setLoopSegmentCount((previousLoopSegmentCount) => previousLoopSegmentCount === nextLoopSegmentCount ? previousLoopSegmentCount : nextLoopSegmentCount);
217
342
  loopPointRef.current = nextLoopPoint;
218
- setLoopPoint((previousLoopPoint) => Math.abs(previousLoopPoint - nextLoopPoint) < .5 ? previousLoopPoint : nextLoopPoint);
219
343
  renderPosition(positionRef.current);
220
344
  }, [isHorizontal, renderPosition]);
221
345
  useEffect(() => {
@@ -223,20 +347,30 @@ function ReactSway({ axis, autoScroll = true, children, direction = "up", dragga
223
347
  if (!autoScroll) {
224
348
  clearInactivityTimer();
225
349
  autoScrollRef.current.active = true;
226
- }
227
- }, [autoScroll, clearInactivityTimer]);
228
- useEffect(() => {
229
- loopPointRef.current = loopPoint;
230
- renderPosition(positionRef.current);
231
- }, [loopPoint, renderPosition]);
350
+ } else startAnimationLoop();
351
+ }, [
352
+ autoScroll,
353
+ clearInactivityTimer,
354
+ startAnimationLoop
355
+ ]);
356
+ const scheduleLoopPointRecalculation = useCallback(() => {
357
+ clearEdgeHoverIntervalCache();
358
+ if (loopMeasurementFrameRef.current !== null) return;
359
+ loopMeasurementFrameRef.current = requestAnimationFrame(() => {
360
+ loopMeasurementFrameRef.current = null;
361
+ recalculateLoopPoint();
362
+ });
363
+ }, [clearEdgeHoverIntervalCache, recalculateLoopPoint]);
232
364
  useEffect(() => {
233
- const rafId = requestAnimationFrame(recalculateLoopPoint);
234
- return () => {
235
- cancelAnimationFrame(rafId);
236
- };
237
- }, [children, recalculateLoopPoint]);
365
+ lastLoopMeasurementRef.current = null;
366
+ scheduleLoopPointRecalculation();
367
+ }, [
368
+ children,
369
+ isHorizontal,
370
+ scheduleLoopPointRecalculation
371
+ ]);
238
372
  const pauseAutoScroll = useCallback(() => {
239
- if (!pauseOnInteraction) return;
373
+ if (!pauseOnInteraction || !autoScrollRef.current.desired || !autoScrollRef.current.active) return;
240
374
  autoScrollRef.current.active = false;
241
375
  onPauseRef.current?.();
242
376
  clearInactivityTimer();
@@ -249,11 +383,13 @@ function ReactSway({ axis, autoScroll = true, children, direction = "up", dragga
249
383
  if (!autoScrollRef.current.desired || isPausedRef.current) return;
250
384
  autoScrollRef.current.active = true;
251
385
  onResumeRef.current?.();
386
+ startAnimationLoop();
252
387
  }, normalizedResumeDelay);
253
388
  }, [
254
389
  clearInactivityTimer,
255
390
  normalizedResumeDelay,
256
- pauseOnInteraction
391
+ pauseOnInteraction,
392
+ startAnimationLoop
257
393
  ]);
258
394
  const togglePause = useCallback(() => {
259
395
  const newPaused = !isPausedRef.current;
@@ -267,8 +403,22 @@ function ReactSway({ axis, autoScroll = true, children, direction = "up", dragga
267
403
  clearInactivityTimer();
268
404
  autoScrollRef.current.active = true;
269
405
  if (autoScrollRef.current.desired) onResumeRef.current?.();
406
+ startAnimationLoop();
270
407
  }
271
- }, [clearInactivityTimer]);
408
+ }, [clearInactivityTimer, startAnimationLoop]);
409
+ const setEdgeHoverIntensity = useCallback((nextIntensityValue) => {
410
+ const nextIntensity = normalizeEdgeHoverIntensity(nextIntensityValue);
411
+ edgeHoverIntensityRef.current = nextIntensity;
412
+ if (nextIntensity !== 0) startAnimationLoop();
413
+ }, [startAnimationLoop]);
414
+ const detachMouseDragListeners = useCallback(() => {
415
+ mouseDragListenerCleanupRef.current?.();
416
+ mouseDragListenerCleanupRef.current = null;
417
+ }, []);
418
+ const detachTouchDragListeners = useCallback(() => {
419
+ touchDragListenerCleanupRef.current?.();
420
+ touchDragListenerCleanupRef.current = null;
421
+ }, []);
272
422
  const handleKeyDown = useCallback((e) => {
273
423
  if (!keyboard) return;
274
424
  switch (e.key) {
@@ -320,25 +470,16 @@ function ReactSway({ axis, autoScroll = true, children, direction = "up", dragga
320
470
  break;
321
471
  default: break;
322
472
  }
473
+ startAnimationLoop();
323
474
  }, [
324
475
  commitPosition,
325
476
  isHorizontal,
326
477
  keyboard,
327
478
  pauseAutoScroll,
328
479
  scheduleAutoScrollResume,
480
+ startAnimationLoop,
329
481
  togglePause
330
482
  ]);
331
- const handleMouseDown = useCallback((e) => {
332
- if (!draggable) return;
333
- e.preventDefault();
334
- containerRef.current?.focus();
335
- setIsDragging(true);
336
- isDraggingRef.current = true;
337
- lastMouseXRef.current = e.clientX;
338
- lastMouseYRef.current = e.clientY;
339
- velocityRef.current = 0;
340
- pauseAutoScroll();
341
- }, [draggable, pauseAutoScroll]);
342
483
  const handleMouseMove = useCallback((e) => {
343
484
  if (!isDraggingRef.current) return;
344
485
  e.preventDefault();
@@ -356,19 +497,77 @@ function ReactSway({ axis, autoScroll = true, children, direction = "up", dragga
356
497
  const handleMouseUp = useCallback((e) => {
357
498
  if (!isDraggingRef.current) return;
358
499
  e.preventDefault();
359
- setIsDragging(false);
360
- isDraggingRef.current = false;
500
+ setDraggingState(false);
501
+ detachMouseDragListeners();
502
+ scheduleAutoScrollResume();
503
+ startAnimationLoop();
504
+ }, [
505
+ detachMouseDragListeners,
506
+ scheduleAutoScrollResume,
507
+ setDraggingState,
508
+ startAnimationLoop
509
+ ]);
510
+ const handleMouseCancel = useCallback(() => {
511
+ if (!isDraggingRef.current) return;
512
+ setDraggingState(false);
513
+ detachMouseDragListeners();
361
514
  scheduleAutoScrollResume();
362
- }, [scheduleAutoScrollResume]);
515
+ startAnimationLoop();
516
+ }, [
517
+ detachMouseDragListeners,
518
+ scheduleAutoScrollResume,
519
+ setDraggingState,
520
+ startAnimationLoop
521
+ ]);
522
+ const attachMouseDragListeners = useCallback(() => {
523
+ detachMouseDragListeners();
524
+ window.addEventListener("mousemove", handleMouseMove);
525
+ window.addEventListener("mouseup", handleMouseUp);
526
+ window.addEventListener("blur", handleMouseCancel);
527
+ mouseDragListenerCleanupRef.current = () => {
528
+ window.removeEventListener("mousemove", handleMouseMove);
529
+ window.removeEventListener("mouseup", handleMouseUp);
530
+ window.removeEventListener("blur", handleMouseCancel);
531
+ };
532
+ }, [
533
+ detachMouseDragListeners,
534
+ handleMouseCancel,
535
+ handleMouseMove,
536
+ handleMouseUp
537
+ ]);
538
+ const handleMouseDown = useCallback((e) => {
539
+ if (!draggable) return;
540
+ e.preventDefault();
541
+ containerRef.current?.focus();
542
+ setDraggingState(true);
543
+ lastMouseXRef.current = e.clientX;
544
+ lastMouseYRef.current = e.clientY;
545
+ velocityRef.current = 0;
546
+ pauseAutoScroll();
547
+ attachMouseDragListeners();
548
+ startAnimationLoop();
549
+ }, [
550
+ attachMouseDragListeners,
551
+ draggable,
552
+ pauseAutoScroll,
553
+ setDraggingState,
554
+ startAnimationLoop
555
+ ]);
363
556
  const handleTouchEnd = useCallback((_e) => {
364
557
  const wasActive = touchInteractionStateRef.current === "active";
365
558
  touchInteractionStateRef.current = "idle";
366
- if (isDraggingRef.current) {
367
- setIsDragging(false);
368
- isDraggingRef.current = false;
559
+ detachTouchDragListeners();
560
+ if (isDraggingRef.current) setDraggingState(false);
561
+ if (wasActive) {
562
+ scheduleAutoScrollResume();
563
+ startAnimationLoop();
369
564
  }
370
- if (wasActive) scheduleAutoScrollResume();
371
- }, [scheduleAutoScrollResume]);
565
+ }, [
566
+ detachTouchDragListeners,
567
+ scheduleAutoScrollResume,
568
+ setDraggingState,
569
+ startAnimationLoop
570
+ ]);
372
571
  const handleTouchMove = useCallback((e) => {
373
572
  if (touchInteractionStateRef.current === "idle" || touchInteractionStateRef.current === "ignored") return;
374
573
  if (e.touches.length !== 1) return;
@@ -384,8 +583,7 @@ function ReactSway({ axis, autoScroll = true, children, direction = "up", dragga
384
583
  return;
385
584
  }
386
585
  touchInteractionStateRef.current = "active";
387
- setIsDragging(true);
388
- isDraggingRef.current = true;
586
+ setDraggingState(true);
389
587
  pauseAutoScroll();
390
588
  }
391
589
  e.preventDefault();
@@ -399,8 +597,24 @@ function ReactSway({ axis, autoScroll = true, children, direction = "up", dragga
399
597
  commitPosition,
400
598
  isHorizontal,
401
599
  pauseAutoScroll,
600
+ setDraggingState,
402
601
  wrapPosition
403
602
  ]);
603
+ const attachTouchDragListeners = useCallback(() => {
604
+ detachTouchDragListeners();
605
+ window.addEventListener("touchmove", handleTouchMove, { passive: false });
606
+ window.addEventListener("touchend", handleTouchEnd, { passive: true });
607
+ window.addEventListener("touchcancel", handleTouchEnd, { passive: true });
608
+ touchDragListenerCleanupRef.current = () => {
609
+ window.removeEventListener("touchmove", handleTouchMove);
610
+ window.removeEventListener("touchend", handleTouchEnd);
611
+ window.removeEventListener("touchcancel", handleTouchEnd);
612
+ };
613
+ }, [
614
+ detachTouchDragListeners,
615
+ handleTouchEnd,
616
+ handleTouchMove
617
+ ]);
404
618
  const handleTouchStart = useCallback((e) => {
405
619
  if (!draggable || e.touches.length !== 1) return;
406
620
  containerRef.current?.focus();
@@ -410,13 +624,14 @@ function ReactSway({ axis, autoScroll = true, children, direction = "up", dragga
410
624
  touchStartXRef.current = e.touches[0].clientX;
411
625
  touchStartYRef.current = e.touches[0].clientY;
412
626
  velocityRef.current = 0;
413
- }, [draggable]);
627
+ attachTouchDragListeners();
628
+ }, [attachTouchDragListeners, draggable]);
414
629
  const handleWheel = useCallback((e) => {
415
- if (!wheelEnabled) return;
630
+ if (!wheelEnabled) return false;
416
631
  const currentContainer = e.currentTarget instanceof HTMLElement ? e.currentTarget : containerRef.current;
417
- if (!currentContainer) return;
632
+ if (!currentContainer) return false;
418
633
  const ownedDelta = getOwnedWheelDelta(e, currentContainer, isHorizontal, wheelMode);
419
- if (ownedDelta === null) return;
634
+ if (ownedDelta === null) return false;
420
635
  e.preventDefault();
421
636
  const wheelDelta = -ownedDelta;
422
637
  const nextPosition = wrapPosition(positionRef.current + wheelDelta * WHEEL_IMMEDIATE_DELTA_FACTOR);
@@ -425,125 +640,155 @@ function ReactSway({ axis, autoScroll = true, children, direction = "up", dragga
425
640
  velocityRef.current = Math.max(-150, Math.min(MAX_VELOCITY, velocityRef.current));
426
641
  pauseAutoScroll();
427
642
  scheduleAutoScrollResume();
643
+ startAnimationLoop();
644
+ return true;
428
645
  }, [
429
646
  commitPosition,
430
647
  isHorizontal,
431
648
  pauseAutoScroll,
432
649
  scheduleAutoScrollResume,
650
+ startAnimationLoop,
433
651
  wheelEnabled,
434
652
  wheelMode,
435
653
  wrapPosition
436
654
  ]);
437
- const handleEdgeHoverMove = useCallback((e) => {
438
- if (!edgeHoverScroll) return;
655
+ const applyEdgeHoverPoint = useCallback((point) => {
656
+ if (!edgeHoverScroll) return false;
439
657
  const viewport = containerRef.current?.parentElement ?? containerRef.current;
440
- if (!viewport) return;
441
- const visibleInterval = getVisibleEdgeInterval(viewport.getBoundingClientRect(), isHorizontal);
658
+ if (!viewport) return false;
659
+ const visibleInterval = readVisibleEdgeInterval(viewport);
442
660
  if (!visibleInterval) {
443
- edgeHoverDirectionRef.current = 0;
444
- return;
445
- }
446
- const pointerPosition = isHorizontal ? e.clientX : e.clientY;
447
- const effectiveEdgeHoverSize = Math.min(normalizedEdgeHoverSize, visibleInterval.size / 2);
448
- if (pointerPosition <= visibleInterval.start + effectiveEdgeHoverSize) {
449
- edgeHoverDirectionRef.current = 1;
450
- return;
451
- }
452
- if (pointerPosition >= visibleInterval.end - effectiveEdgeHoverSize) {
453
- edgeHoverDirectionRef.current = -1;
454
- return;
661
+ setEdgeHoverIntensity(0);
662
+ return false;
455
663
  }
456
- edgeHoverDirectionRef.current = 0;
664
+ const nextIntensity = getEdgeHoverIntensity(isHorizontal ? point.clientX : point.clientY, visibleInterval, normalizedEdgeHoverSize);
665
+ setEdgeHoverIntensity(nextIntensity);
666
+ return true;
457
667
  }, [
458
668
  edgeHoverScroll,
459
669
  isHorizontal,
460
- normalizedEdgeHoverSize
670
+ normalizedEdgeHoverSize,
671
+ readVisibleEdgeInterval,
672
+ setEdgeHoverIntensity
461
673
  ]);
674
+ const handleEdgeHover = useCallback((point) => {
675
+ const accepted = applyEdgeHoverPoint(point);
676
+ externalEdgeHoverLeaseRef.current = accepted;
677
+ return accepted;
678
+ }, [applyEdgeHoverPoint]);
679
+ const clearEdgeHover = useCallback(() => {
680
+ externalEdgeHoverLeaseRef.current = false;
681
+ setEdgeHoverIntensity(0);
682
+ }, [setEdgeHoverIntensity]);
683
+ useImperativeHandle(ref, () => ({
684
+ clearEdgeHover,
685
+ handleEdgeHover,
686
+ handleWheel
687
+ }), [
688
+ clearEdgeHover,
689
+ handleEdgeHover,
690
+ handleWheel
691
+ ]);
692
+ const handleEdgeHoverMove = useCallback((e) => {
693
+ if (externalEdgeHoverLeaseRef.current) return;
694
+ applyEdgeHoverPoint(e);
695
+ }, [applyEdgeHoverPoint]);
462
696
  const handleEdgeHoverLeave = useCallback(() => {
463
- edgeHoverDirectionRef.current = 0;
464
- }, []);
697
+ if (externalEdgeHoverLeaseRef.current) return;
698
+ setEdgeHoverIntensity(0);
699
+ }, [setEdgeHoverIntensity]);
465
700
  useEffect(() => {
466
701
  const currentContainer = containerRef.current;
467
702
  if (!currentContainer) return;
468
- currentContainer.addEventListener("mousedown", handleMouseDown);
469
- window.addEventListener("mousemove", handleMouseMove);
470
- window.addEventListener("mouseup", handleMouseUp);
471
703
  if (draggable) {
704
+ currentContainer.addEventListener("mousedown", handleMouseDown);
472
705
  currentContainer.addEventListener("touchstart", handleTouchStart, { passive: true });
473
- window.addEventListener("touchmove", handleTouchMove, { passive: false });
474
- window.addEventListener("touchend", handleTouchEnd, { passive: true });
475
706
  }
476
707
  if (wheelEnabled) currentContainer.addEventListener("wheel", handleWheel, { passive: false });
477
708
  const edgeHoverTarget = currentContainer.parentElement ?? currentContainer;
478
- edgeHoverTarget.addEventListener("mousemove", handleEdgeHoverMove);
479
- edgeHoverTarget.addEventListener("mouseleave", handleEdgeHoverLeave);
709
+ const usesPointerEdgeHover = typeof window.PointerEvent === "function";
710
+ const usesNativeEdgeHover = edgeHoverScroll && edgeHoverInputMode === "hybrid";
711
+ if (usesNativeEdgeHover && usesPointerEdgeHover) {
712
+ edgeHoverTarget.addEventListener("pointermove", handleEdgeHoverMove);
713
+ edgeHoverTarget.addEventListener("pointerleave", handleEdgeHoverLeave);
714
+ edgeHoverTarget.addEventListener("pointercancel", clearEdgeHover);
715
+ } else if (usesNativeEdgeHover) {
716
+ edgeHoverTarget.addEventListener("mousemove", handleEdgeHoverMove);
717
+ edgeHoverTarget.addEventListener("mouseleave", handleEdgeHoverLeave);
718
+ }
480
719
  return () => {
481
- currentContainer.removeEventListener("mousedown", handleMouseDown);
482
- window.removeEventListener("mousemove", handleMouseMove);
483
- window.removeEventListener("mouseup", handleMouseUp);
484
720
  if (draggable) {
721
+ currentContainer.removeEventListener("mousedown", handleMouseDown);
485
722
  currentContainer.removeEventListener("touchstart", handleTouchStart);
486
- window.removeEventListener("touchmove", handleTouchMove);
487
- window.removeEventListener("touchend", handleTouchEnd);
488
723
  }
489
724
  if (wheelEnabled) currentContainer.removeEventListener("wheel", handleWheel);
490
- edgeHoverTarget.removeEventListener("mousemove", handleEdgeHoverMove);
491
- edgeHoverTarget.removeEventListener("mouseleave", handleEdgeHoverLeave);
725
+ if (usesNativeEdgeHover && usesPointerEdgeHover) {
726
+ edgeHoverTarget.removeEventListener("pointermove", handleEdgeHoverMove);
727
+ edgeHoverTarget.removeEventListener("pointerleave", handleEdgeHoverLeave);
728
+ edgeHoverTarget.removeEventListener("pointercancel", clearEdgeHover);
729
+ } else if (usesNativeEdgeHover) {
730
+ edgeHoverTarget.removeEventListener("mousemove", handleEdgeHoverMove);
731
+ edgeHoverTarget.removeEventListener("mouseleave", handleEdgeHoverLeave);
732
+ }
733
+ detachMouseDragListeners();
734
+ detachTouchDragListeners();
492
735
  };
493
736
  }, [
737
+ detachMouseDragListeners,
738
+ detachTouchDragListeners,
494
739
  draggable,
740
+ edgeHoverScroll,
741
+ edgeHoverInputMode,
742
+ clearEdgeHover,
495
743
  handleEdgeHoverLeave,
496
744
  handleEdgeHoverMove,
497
745
  handleMouseDown,
498
- handleMouseMove,
499
- handleMouseUp,
500
- handleTouchEnd,
501
- handleTouchMove,
502
746
  handleTouchStart,
503
747
  handleWheel,
504
748
  wheelEnabled
505
749
  ]);
506
- const debouncedRecalculate = useCallback(() => {
507
- if (resizeDebounceTimerRef.current) clearTimeout(resizeDebounceTimerRef.current);
508
- resizeDebounceTimerRef.current = setTimeout(() => {
509
- resizeDebounceTimerRef.current = null;
510
- recalculateLoopPoint();
511
- }, RESIZE_DEBOUNCE_MS);
512
- }, [recalculateLoopPoint]);
513
750
  useEffect(() => {
514
- window.addEventListener("resize", debouncedRecalculate);
751
+ window.addEventListener("resize", scheduleLoopPointRecalculation);
515
752
  return () => {
516
- window.removeEventListener("resize", debouncedRecalculate);
753
+ window.removeEventListener("resize", scheduleLoopPointRecalculation);
517
754
  };
518
- }, [debouncedRecalculate]);
755
+ }, [scheduleLoopPointRecalculation]);
519
756
  useEffect(() => {
520
- if (!containerRef.current || typeof ResizeObserver === "undefined") return;
757
+ if (!containerRef.current || !originalGroupRef.current || typeof ResizeObserver === "undefined") return;
758
+ const currentContainer = containerRef.current;
759
+ const originalGroup = originalGroupRef.current;
760
+ const viewport = currentContainer.parentElement ?? currentContainer;
521
761
  const observer = new ResizeObserver(() => {
522
- debouncedRecalculate();
762
+ scheduleLoopPointRecalculation();
523
763
  });
524
- observer.observe(containerRef.current);
764
+ observer.observe(originalGroup);
765
+ if (viewport !== originalGroup) observer.observe(viewport);
525
766
  return () => {
526
767
  observer.disconnect();
527
768
  };
528
- }, [debouncedRecalculate]);
769
+ }, [scheduleLoopPointRecalculation]);
529
770
  useEffect(() => {
530
771
  return () => {
531
- if (resizeDebounceTimerRef.current) {
532
- clearTimeout(resizeDebounceTimerRef.current);
533
- resizeDebounceTimerRef.current = null;
772
+ if (loopMeasurementFrameRef.current !== null) {
773
+ cancelAnimationFrame(loopMeasurementFrameRef.current);
774
+ loopMeasurementFrameRef.current = null;
534
775
  }
776
+ clearEdgeHoverIntervalCache();
535
777
  };
536
- }, []);
778
+ }, [clearEdgeHoverIntervalCache]);
537
779
  useEffect(() => {
538
780
  const handleVisibilityChange = () => {
539
781
  setIsTabActive(!document.hidden);
540
- if (!document.hidden) lastFrameTimeRef.current = performance.now();
782
+ if (!document.hidden) {
783
+ lastFrameTimeRef.current = performance.now();
784
+ startAnimationLoop();
785
+ }
541
786
  };
542
787
  document.addEventListener("visibilitychange", handleVisibilityChange);
543
788
  return () => {
544
789
  document.removeEventListener("visibilitychange", handleVisibilityChange);
545
790
  };
546
- }, []);
791
+ }, [startAnimationLoop]);
547
792
  useEffect(() => {
548
793
  return () => {
549
794
  clearInactivityTimer();
@@ -551,10 +796,11 @@ function ReactSway({ axis, autoScroll = true, children, direction = "up", dragga
551
796
  }, [clearInactivityTimer]);
552
797
  useEffect(() => {
553
798
  if (!isTabActive || isPaused) {
554
- if (animationFrameRef.current) {
799
+ if (animationFrameRef.current !== null) {
555
800
  cancelAnimationFrame(animationFrameRef.current);
556
801
  animationFrameRef.current = null;
557
802
  }
803
+ animationLoopRef.current = null;
558
804
  return;
559
805
  }
560
806
  lastFrameTimeRef.current = 0;
@@ -568,27 +814,43 @@ function ReactSway({ axis, autoScroll = true, children, direction = "up", dragga
568
814
  else velocityRef.current = 0;
569
815
  let nextPosition = positionRef.current;
570
816
  const effectiveSpeed = prefersReducedMotion ? normalizedSpeed * REDUCED_MOTION_SPEED_FACTOR : normalizedSpeed;
571
- const edgeHoverMultiplier = edgeHoverScroll ? edgeHoverDirectionRef.current : directionMultiplier;
572
- if (autoScrollRef.current.desired && autoScrollRef.current.active && !isDraggingRef.current && (!edgeHoverScroll || edgeHoverMultiplier !== 0)) nextPosition += edgeHoverMultiplier * effectiveSpeed * deltaTime;
573
- if (!isDraggingRef.current && Math.abs(velocityRef.current) > .1) nextPosition += velocityRef.current * deltaTime;
817
+ const edgeHoverMultiplier = edgeHoverScroll ? getEdgeHoverVelocityScale(edgeHoverIntensityRef.current, normalizedEdgeHoverSpeedMultiplier) : directionMultiplier;
818
+ const shouldAutoScroll = (autoScrollRef.current.desired && autoScrollRef.current.active || externalEdgeHoverLeaseRef.current) && !isDraggingRef.current && (!edgeHoverScroll || edgeHoverMultiplier !== 0);
819
+ if (shouldAutoScroll) {
820
+ const autoScrollDeltaTime = edgeHoverScroll ? Math.min(deltaTime, MAX_EDGE_HOVER_DELTA_TIME) : deltaTime;
821
+ nextPosition += edgeHoverMultiplier * effectiveSpeed * autoScrollDeltaTime;
822
+ }
823
+ const shouldApplyMomentum = !isDraggingRef.current && Math.abs(velocityRef.current) > .1;
824
+ if (shouldApplyMomentum) nextPosition += velocityRef.current * deltaTime;
574
825
  nextPosition = wrapPosition(nextPosition);
575
826
  commitPosition(nextPosition);
576
- animationFrameRef.current = requestAnimationFrame(animate);
827
+ if (isDraggingRef.current || shouldAutoScroll || shouldApplyMomentum) {
828
+ animationFrameRef.current = requestAnimationFrame(animate);
829
+ return;
830
+ }
831
+ animationFrameRef.current = null;
832
+ lastFrameTimeRef.current = 0;
577
833
  };
578
- animationFrameRef.current = requestAnimationFrame(animate);
834
+ animationLoopRef.current = animate;
835
+ startAnimationLoop();
579
836
  return () => {
580
- if (animationFrameRef.current) cancelAnimationFrame(animationFrameRef.current);
837
+ if (animationFrameRef.current !== null) {
838
+ cancelAnimationFrame(animationFrameRef.current);
839
+ animationFrameRef.current = null;
840
+ }
841
+ if (animationLoopRef.current === animate) animationLoopRef.current = null;
581
842
  };
582
843
  }, [
583
844
  commitPosition,
584
845
  direction,
585
846
  edgeHoverScroll,
586
- isHorizontal,
587
847
  isPaused,
588
848
  isTabActive,
589
849
  normalizedFriction,
850
+ normalizedEdgeHoverSpeedMultiplier,
590
851
  normalizedSpeed,
591
852
  prefersReducedMotion,
853
+ startAnimationLoop,
592
854
  wrapPosition
593
855
  ]);
594
856
  useEffect(() => {
@@ -600,7 +862,7 @@ function ReactSway({ axis, autoScroll = true, children, direction = "up", dragga
600
862
  }, {
601
863
  root: null,
602
864
  rootMargin: lazyRootMargin,
603
- threshold: lazyThreshold
865
+ threshold: normalizedLazyThreshold
604
866
  });
605
867
  const items = containerRef.current.querySelectorAll(".content-item");
606
868
  items.forEach((item) => observer.observe(item));
@@ -612,13 +874,14 @@ function ReactSway({ axis, autoScroll = true, children, direction = "up", dragga
612
874
  children,
613
875
  lazy,
614
876
  lazyRootMargin,
615
- lazyThreshold
877
+ normalizedLazyThreshold
616
878
  ]);
617
- return /* @__PURE__ */ jsxs("div", {
618
- className: "react-sway-container scroller-content",
879
+ return /* @__PURE__ */ jsxs("section", {
880
+ "aria-label": ariaLabel,
881
+ className: `react-sway-container scroller-content${className ? ` ${className}` : ""}`,
619
882
  ref: containerRef,
620
883
  style: {
621
- cursor: draggable ? isDragging ? "grabbing" : "grab" : "default",
884
+ cursor: draggable ? isDraggingRef.current ? "grabbing" : "grab" : "default",
622
885
  display: isHorizontal ? "flex" : void 0,
623
886
  flexDirection: isHorizontal ? "row" : void 0,
624
887
  height: isHorizontal ? "100%" : "max-content",
@@ -651,14 +914,16 @@ function ReactSway({ axis, autoScroll = true, children, direction = "up", dragga
651
914
  "aria-hidden": "true",
652
915
  className: "content-group duplicate",
653
916
  "data-duplicate": "true",
917
+ inert: true,
654
918
  role: "presentation",
655
919
  style: isHorizontal ? {
656
920
  display: "flex",
657
- flex: "0 0 auto"
658
- } : void 0,
921
+ flex: "0 0 auto",
922
+ pointerEvents: "none"
923
+ } : { pointerEvents: "none" },
659
924
  children
660
925
  }, `duplicate-${duplicateIndex}`))]
661
926
  });
662
- }
927
+ });
663
928
  //#endregion
664
- export { ReactSway };
929
+ export { DEFAULT_EDGE_HOVER_SIZE, DEFAULT_EDGE_HOVER_SPEED_MULTIPLIER, ReactSway };