react-sway 0.1.0 → 0.2.0

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/src/ReactSway.tsx CHANGED
@@ -1,248 +1,463 @@
1
- import { useState, useRef, useEffect, useCallback, ReactNode } from 'react';
1
+ import { type KeyboardEvent as ReactKeyboardEvent, type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
2
 
3
- // Constants (hardcoded as per current plan)
4
- const SCROLL_SPEED = 0.5; // pixels per frame at 60fps
5
- const INACTIVITY_DELAY = 2000; // ms
6
- const FRICTION = 0.95;
7
- const MAX_DELTA_TIME = 3; // Cap deltaTime to prevent physics breaking
3
+ /** Velocity applied per arrow key press (pixels). */
4
+ const ARROW_KEY_VELOCITY = 15;
8
5
 
9
- interface ReactSwayProps {
6
+ /** Default friction coefficient applied to velocity each frame. */
7
+ const DEFAULT_FRICTION = 0.95;
8
+
9
+ /** Default IntersectionObserver rootMargin for lazy visibility detection. */
10
+ const DEFAULT_LAZY_ROOT_MARGIN = '100px';
11
+
12
+ /** Default IntersectionObserver threshold for lazy visibility detection. */
13
+ const DEFAULT_LAZY_THRESHOLD = 0.01;
14
+
15
+ /** Default delay in milliseconds before auto-scroll resumes after user interaction. */
16
+ const DEFAULT_RESUME_DELAY = 2000;
17
+
18
+ /** Default scroll speed in pixels per frame at 60fps. */
19
+ const DEFAULT_SPEED = 0.5;
20
+
21
+ /** Number of stacked content groups used to build the seamless loop. */
22
+ const LOOP_SEGMENTS = 3;
23
+
24
+ /** Maximum deltaTime cap to prevent physics instability during frame drops. */
25
+ const MAX_DELTA_TIME = 3;
26
+
27
+ /** Maximum allowed velocity magnitude to prevent runaway scrolling. */
28
+ const MAX_VELOCITY = 150;
29
+
30
+ /** Duration of a single frame at 60fps in milliseconds. */
31
+ const MS_PER_FRAME_60FPS = 16.667;
32
+
33
+ /** Speed multiplier when user prefers reduced motion (25% of normal). */
34
+ const REDUCED_MOTION_SPEED_FACTOR = 0.25;
35
+
36
+ /** Debounce delay in milliseconds for ResizeObserver callbacks. */
37
+ const RESIZE_DEBOUNCE_MS = 150;
38
+
39
+ /** Multiplier applied to wheel deltaY to convert to scroll velocity. */
40
+ const WHEEL_VELOCITY_MULTIPLIER = 0.3;
41
+
42
+ /**
43
+ * Props for the ReactSway infinite scrolling component.
44
+ */
45
+ export interface ReactSwayProps {
46
+ /** Enable/disable auto-scrolling. @default true */
47
+ autoScroll?: boolean;
48
+ /** Content elements to render in the infinite scroll container. */
10
49
  children: ReactNode;
50
+ /** Auto-scroll direction. @default 'up' */
51
+ direction?: 'down' | 'up';
52
+ /** Enable mouse/touch drag interaction. @default true */
53
+ draggable?: boolean;
54
+ /** Momentum decay coefficient (0-1, lower = more friction). @default 0.95 */
55
+ friction?: number;
56
+ /** Enable keyboard controls (Space, ArrowUp/Down, Home/End). @default true */
57
+ keyboard?: boolean;
58
+ /** Enable lazy visibility detection via IntersectionObserver. @default true */
59
+ lazy?: boolean;
60
+ /** IntersectionObserver rootMargin for lazy visibility detection. @default '100px' */
61
+ lazyRootMargin?: string;
62
+ /** IntersectionObserver threshold for lazy visibility detection. @default 0.01 */
63
+ lazyThreshold?: number;
64
+ /** Fired when scrolling pauses (user interaction or Space key). */
65
+ onPause?: () => void;
66
+ /** Fired when scrolling resumes after pause. */
67
+ onResume?: () => void;
68
+ /** Fired on every position change with the current scroll position. */
69
+ onScroll?: (position: number) => void;
70
+ /** Pause auto-scroll during user interaction. @default true */
71
+ pauseOnInteraction?: boolean;
72
+ /** Milliseconds before auto-scroll resumes after interaction. @default 2000 */
73
+ resumeDelay?: number;
74
+ /** Auto-scroll speed in pixels per frame at 60fps. @default 0.5 */
75
+ speed?: number;
76
+ /** Enable mouse wheel scrolling. @default true */
77
+ wheelEnabled?: boolean;
11
78
  }
12
79
 
13
- function ReactSway({ children }: ReactSwayProps) {
14
- const [position, setPosition] = useState(0);
15
- const [, setVelocity] = useState(0); // velocity is set but never used directly for rendering, only for physics calcs
80
+ /**
81
+ * A smooth, infinite scrolling container component.
82
+ *
83
+ * Renders children in a continuously looping scroll area with support for
84
+ * auto-scrolling, mouse drag, touch swipe, wheel, and keyboard interactions.
85
+ * Content is duplicated to create a seamless loop effect. Duplicate content
86
+ * is wrapped in `<aside>` elements with `aria-hidden="true"` for accessibility.
87
+ *
88
+ * Respects `prefers-reduced-motion: reduce` by lowering auto-scroll speed
89
+ * and disabling momentum effects.
90
+ *
91
+ * @example
92
+ * ```tsx
93
+ * <ReactSway direction="up" speed={1} friction={0.9}>
94
+ * <div className="content-item">Item 1</div>
95
+ * <div className="content-item">Item 2</div>
96
+ * </ReactSway>
97
+ * ```
98
+ */
99
+ function ReactSway({
100
+ autoScroll = true,
101
+ children,
102
+ direction = 'up',
103
+ draggable = true,
104
+ friction = DEFAULT_FRICTION,
105
+ keyboard = true,
106
+ lazy = true,
107
+ lazyRootMargin = DEFAULT_LAZY_ROOT_MARGIN,
108
+ lazyThreshold = DEFAULT_LAZY_THRESHOLD,
109
+ onPause,
110
+ onResume,
111
+ onScroll,
112
+ pauseOnInteraction = true,
113
+ resumeDelay = DEFAULT_RESUME_DELAY,
114
+ speed = DEFAULT_SPEED,
115
+ wheelEnabled = true,
116
+ }: ReactSwayProps) {
117
+ const normalizedFriction = useMemo(
118
+ () => (Number.isFinite(friction) ? Math.min(Math.max(friction, 0), 1) : DEFAULT_FRICTION),
119
+ [friction],
120
+ );
121
+ const normalizedResumeDelay = useMemo(
122
+ () => (Number.isFinite(resumeDelay) ? Math.max(0, resumeDelay) : DEFAULT_RESUME_DELAY),
123
+ [resumeDelay],
124
+ );
125
+ const normalizedSpeed = useMemo(
126
+ () => (Number.isFinite(speed) ? Math.max(0, speed) : DEFAULT_SPEED),
127
+ [speed],
128
+ );
129
+
16
130
  const [isDragging, setIsDragging] = useState(false);
17
131
  const [isPaused, setIsPaused] = useState(false);
18
- const [autoScrollEnabled, setAutoScrollEnabled] = useState(true);
19
132
  const [isTabActive, setIsTabActive] = useState(true);
20
-
21
- // Content dimensions
22
- const [contentHeight, setContentHeight] = useState(0);
23
133
  const [loopPoint, setLoopPoint] = useState(0);
24
- const [, setContainerHeight] = useState(0); // containerHeight is set but never used
134
+ const [prefersReducedMotion, setPrefersReducedMotion] = useState(() => {
135
+ if (typeof window === 'undefined') return false;
136
+ return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
137
+ });
25
138
 
26
- const containerRef = useRef<HTMLDivElement>(null);
27
139
  const animationFrameRef = useRef<number | null>(null);
140
+ const autoScrollRef = useRef({ active: autoScroll, desired: autoScroll });
141
+ const containerRef = useRef<HTMLDivElement>(null);
28
142
  const inactivityTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
29
- const lastTouchYRef = useRef(0);
30
- const lastMouseYRef = useRef(0);
143
+ const isDraggingRef = useRef(false);
144
+ const isPausedRef = useRef(false);
31
145
  const lastFrameTimeRef = useRef(0);
32
-
33
- // Visual position calculation
34
- let visualPosition = position % (loopPoint || 1);
35
- if (visualPosition > 0 && loopPoint > 0) visualPosition -= loopPoint;
36
-
146
+ const lastMouseYRef = useRef(0);
147
+ const lastTouchYRef = useRef(0);
148
+ const loopPointRef = useRef(0);
149
+ const onPauseRef = useRef(onPause);
150
+ const onResumeRef = useRef(onResume);
151
+ const onScrollRef = useRef(onScroll);
152
+ const positionRef = useRef(0);
153
+ const resizeDebounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
154
+ const velocityRef = useRef(0);
155
+
156
+ // Keep callback refs in sync without triggering re-renders
37
157
  useEffect(() => {
38
- const calculateDimensions = () => {
39
- if (containerRef.current) {
40
- // Force a reflow to ensure accurate measurements
41
- // eslint-disable-next-line @typescript-eslint/no-unused-expressions
42
- containerRef.current.offsetHeight;
43
-
44
- const currentContentHeight = containerRef.current.scrollHeight;
45
- const calculatedLoopPoint = currentContentHeight / 3;
46
-
47
- console.log('Calculating dimensions:', { currentContentHeight, calculatedLoopPoint });
158
+ onPauseRef.current = onPause;
159
+ onResumeRef.current = onResume;
160
+ onScrollRef.current = onScroll;
161
+ }, [onPause, onResume, onScroll]);
48
162
 
49
- if (currentContentHeight > 0) {
50
- setContentHeight(currentContentHeight);
51
- setLoopPoint(calculatedLoopPoint);
52
- }
53
-
54
- setContainerHeight(window.innerHeight);
55
- }
163
+ // Listen for prefers-reduced-motion media query changes
164
+ useEffect(() => {
165
+ if (typeof window === 'undefined') return;
166
+ const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
167
+ const handleChange = (e: MediaQueryListEvent) => {
168
+ setPrefersReducedMotion(e.matches);
56
169
  };
57
-
58
- // Use RAF to ensure layout is complete
59
- const rafId = requestAnimationFrame(() => {
60
- calculateDimensions();
61
- });
62
-
170
+ mediaQuery.addEventListener('change', handleChange);
63
171
  return () => {
64
- cancelAnimationFrame(rafId);
172
+ mediaQuery.removeEventListener('change', handleChange);
65
173
  };
66
- }, [children]);
174
+ }, []);
67
175
 
68
- const pauseAutoScroll = useCallback(() => {
69
- setAutoScrollEnabled(false);
70
- if (inactivityTimerRef.current) {
71
- clearTimeout(inactivityTimerRef.current);
72
- }
176
+ const clearInactivityTimer = useCallback(() => {
177
+ if (!inactivityTimerRef.current) return;
178
+ clearTimeout(inactivityTimerRef.current);
179
+ inactivityTimerRef.current = null;
73
180
  }, []);
74
181
 
75
- const scheduleAutoScrollResume = useCallback(() => {
76
- if (inactivityTimerRef.current) {
77
- clearTimeout(inactivityTimerRef.current);
182
+ const renderPosition = useCallback((rawPosition: number) => {
183
+ if (!containerRef.current) return;
184
+ const currentLoopPoint = loopPointRef.current;
185
+ let visualPosition = rawPosition % (currentLoopPoint || 1);
186
+ if (visualPosition > 0 && currentLoopPoint > 0) {
187
+ visualPosition -= currentLoopPoint;
78
188
  }
79
- inactivityTimerRef.current = setTimeout(() => {
80
- setAutoScrollEnabled(true);
81
- }, INACTIVITY_DELAY);
189
+ containerRef.current.style.transform = `translate3d(0, ${visualPosition}px, 0)`;
82
190
  }, []);
83
191
 
84
- const handleMouseDown = useCallback((e: globalThis.MouseEvent) => {
85
- e.preventDefault();
86
- setIsDragging(true);
87
- lastMouseYRef.current = e.clientY;
88
- setVelocity(0);
89
- pauseAutoScroll();
90
- }, [pauseAutoScroll]);
192
+ const commitPosition = useCallback((nextPosition: number) => {
193
+ if (positionRef.current === nextPosition) return;
194
+ positionRef.current = nextPosition;
195
+ renderPosition(nextPosition);
196
+ onScrollRef.current?.(nextPosition);
197
+ }, [renderPosition]);
91
198
 
92
- const handleMouseMove = useCallback((e: globalThis.MouseEvent) => {
93
- if (!isDragging) return;
94
- e.preventDefault();
95
- const deltaY = e.clientY - lastMouseYRef.current;
96
- setPosition(prev => prev + deltaY);
97
- setVelocity(deltaY);
98
- lastMouseYRef.current = e.clientY;
99
- }, [isDragging]);
199
+ const wrapPosition = useCallback((rawPosition: number) => {
200
+ const currentLoopPoint = loopPointRef.current;
201
+ if (currentLoopPoint <= 0) return rawPosition;
100
202
 
101
- const handleMouseUp = useCallback((e: globalThis.MouseEvent) => {
102
- if (!isDragging) return;
103
- e.preventDefault();
104
- setIsDragging(false);
105
- scheduleAutoScrollResume();
106
- }, [isDragging, scheduleAutoScrollResume]);
203
+ let wrappedPosition = rawPosition;
204
+ while (wrappedPosition > 0) {
205
+ wrappedPosition -= currentLoopPoint;
206
+ }
207
+ while (wrappedPosition < -currentLoopPoint * 2) {
208
+ wrappedPosition += currentLoopPoint;
209
+ }
210
+ return wrappedPosition;
211
+ }, []);
107
212
 
108
- const handleTouchStart = useCallback((e: globalThis.TouchEvent) => {
109
- if (e.touches.length === 1) {
110
- setIsDragging(true);
111
- lastTouchYRef.current = e.touches[0].clientY;
112
- setVelocity(0);
113
- pauseAutoScroll();
213
+ const recalculateLoopPoint = useCallback(() => {
214
+ if (!containerRef.current) return;
215
+ const currentContentHeight = containerRef.current.scrollHeight;
216
+ if (currentContentHeight <= 0) return;
217
+
218
+ const nextLoopPoint = currentContentHeight / LOOP_SEGMENTS;
219
+ loopPointRef.current = nextLoopPoint;
220
+ setLoopPoint((previousLoopPoint) => (Math.abs(previousLoopPoint - nextLoopPoint) < 0.5 ? previousLoopPoint : nextLoopPoint));
221
+ renderPosition(positionRef.current);
222
+ }, [renderPosition]);
223
+
224
+ // Sync autoScroll prop changes with internal state
225
+ useEffect(() => {
226
+ autoScrollRef.current.desired = autoScroll;
227
+ if (!autoScroll) {
228
+ clearInactivityTimer();
229
+ autoScrollRef.current.active = true;
114
230
  }
115
- }, [pauseAutoScroll]);
231
+ }, [autoScroll, clearInactivityTimer]);
116
232
 
117
- const handleTouchMove = useCallback((e: globalThis.TouchEvent) => {
118
- if (!isDragging || e.touches.length !== 1) return;
119
- e.preventDefault();
120
- const touch = e.touches[0];
121
- const deltaY = touch.clientY - lastTouchYRef.current;
122
- setPosition(prev => prev + deltaY);
123
- setVelocity(deltaY);
124
- lastTouchYRef.current = touch.clientY;
125
- }, [isDragging]);
233
+ // Sync loopPoint ref after state updates from observers/resizes
234
+ useEffect(() => {
235
+ loopPointRef.current = loopPoint;
236
+ renderPosition(positionRef.current);
237
+ }, [loopPoint, renderPosition]);
126
238
 
127
- const handleTouchEnd = useCallback((_e: globalThis.TouchEvent) => { // e is not used
128
- if (!isDragging) return;
129
- setIsDragging(false);
130
- scheduleAutoScrollResume();
131
- }, [isDragging, scheduleAutoScrollResume]);
239
+ // Dimension calculation
240
+ useEffect(() => {
241
+ // Use RAF to ensure layout is complete
242
+ const rafId = requestAnimationFrame(recalculateLoopPoint);
132
243
 
133
- const handleWheel = useCallback((e: globalThis.WheelEvent) => {
134
- e.preventDefault();
135
- setVelocity(prev => prev - e.deltaY * 0.3);
136
- pauseAutoScroll();
137
- scheduleAutoScrollResume();
138
- }, [pauseAutoScroll, scheduleAutoScrollResume]);
244
+ return () => {
245
+ cancelAnimationFrame(rafId);
246
+ };
247
+ }, [children, recalculateLoopPoint]);
248
+
249
+ const pauseAutoScroll = useCallback(() => {
250
+ if (!pauseOnInteraction) return;
251
+ autoScrollRef.current.active = false;
252
+ onPauseRef.current?.();
253
+ clearInactivityTimer();
254
+ }, [clearInactivityTimer, pauseOnInteraction]);
255
+
256
+ const scheduleAutoScrollResume = useCallback(() => {
257
+ if (!pauseOnInteraction || !autoScrollRef.current.desired || isPausedRef.current) return;
258
+ clearInactivityTimer();
259
+
260
+ inactivityTimerRef.current = setTimeout(() => {
261
+ inactivityTimerRef.current = null;
262
+ if (!autoScrollRef.current.desired || isPausedRef.current) return;
263
+ autoScrollRef.current.active = true;
264
+ onResumeRef.current?.();
265
+ }, normalizedResumeDelay);
266
+ }, [clearInactivityTimer, normalizedResumeDelay, pauseOnInteraction]);
139
267
 
140
268
  const togglePause = useCallback(() => {
141
- setIsPaused(prev => {
142
- const newPausedState = !prev;
143
- if (newPausedState) {
144
- pauseAutoScroll();
145
- } else {
146
- setAutoScrollEnabled(true);
269
+ const newPaused = !isPausedRef.current;
270
+ isPausedRef.current = newPaused;
271
+ setIsPaused(newPaused);
272
+ if (newPaused) {
273
+ clearInactivityTimer();
274
+ autoScrollRef.current.active = false;
275
+ onPauseRef.current?.();
276
+ } else {
277
+ clearInactivityTimer();
278
+ autoScrollRef.current.active = true;
279
+ if (autoScrollRef.current.desired) {
280
+ onResumeRef.current?.();
147
281
  }
148
- return newPausedState;
149
- });
150
- }, [pauseAutoScroll]);
282
+ }
283
+ }, [clearInactivityTimer]);
284
+
285
+ const handleKeyDown = useCallback((e: ReactKeyboardEvent<HTMLDivElement>) => {
286
+ if (!keyboard) return;
151
287
 
152
- const handleKeyDown = useCallback((e: globalThis.KeyboardEvent) => {
153
288
  switch (e.key) {
154
289
  case ' ':
155
290
  e.preventDefault();
156
291
  togglePause();
157
292
  break;
158
- case 'ArrowUp':
293
+ case 'ArrowDown':
159
294
  e.preventDefault();
160
- setVelocity(prev => prev + 15);
295
+ velocityRef.current -= ARROW_KEY_VELOCITY;
161
296
  pauseAutoScroll();
162
297
  scheduleAutoScrollResume();
163
298
  break;
164
- case 'ArrowDown':
299
+ case 'ArrowUp':
165
300
  e.preventDefault();
166
- setVelocity(prev => prev - 15);
301
+ velocityRef.current += ARROW_KEY_VELOCITY;
167
302
  pauseAutoScroll();
168
303
  scheduleAutoScrollResume();
169
304
  break;
170
- case 'Home':
305
+ case 'End':
171
306
  e.preventDefault();
172
- setPosition(0);
173
- setVelocity(0);
307
+ if (loopPointRef.current > 0) {
308
+ commitPosition(-loopPointRef.current);
309
+ }
310
+ velocityRef.current = 0;
174
311
  pauseAutoScroll();
175
312
  scheduleAutoScrollResume();
176
313
  break;
177
- case 'End':
314
+ case 'Home':
178
315
  e.preventDefault();
179
- if (loopPoint > 0) {
180
- setPosition(-loopPoint);
181
- }
182
- setVelocity(0);
316
+ commitPosition(0);
317
+ velocityRef.current = 0;
183
318
  pauseAutoScroll();
184
319
  scheduleAutoScrollResume();
185
320
  break;
186
321
  default:
187
322
  break;
188
323
  }
189
- }, [togglePause, pauseAutoScroll, scheduleAutoScrollResume, loopPoint]);
190
-
191
- const handleResize = useCallback(() => {
192
- setContainerHeight(window.innerHeight);
193
- if (containerRef.current) {
194
- const currentContentHeight = containerRef.current.scrollHeight;
195
- setContentHeight(currentContentHeight);
196
- setLoopPoint(currentContentHeight / 3);
197
- }
198
- }, []);
324
+ }, [commitPosition, keyboard, pauseAutoScroll, scheduleAutoScrollResume, togglePause]);
325
+
326
+ const handleMouseDown = useCallback((e: globalThis.MouseEvent) => {
327
+ if (!draggable) return;
328
+ e.preventDefault();
329
+ containerRef.current?.focus();
330
+ setIsDragging(true);
331
+ isDraggingRef.current = true;
332
+ lastMouseYRef.current = e.clientY;
333
+ velocityRef.current = 0;
334
+ pauseAutoScroll();
335
+ }, [draggable, pauseAutoScroll]);
336
+
337
+ const handleMouseMove = useCallback((e: globalThis.MouseEvent) => {
338
+ if (!isDraggingRef.current) return;
339
+ e.preventDefault();
340
+ const deltaY = e.clientY - lastMouseYRef.current;
341
+ const nextPosition = wrapPosition(positionRef.current + deltaY);
342
+ commitPosition(nextPosition);
343
+ velocityRef.current = deltaY;
344
+ lastMouseYRef.current = e.clientY;
345
+ }, [commitPosition, wrapPosition]);
346
+
347
+ const handleMouseUp = useCallback((e: globalThis.MouseEvent) => {
348
+ if (!isDraggingRef.current) return;
349
+ e.preventDefault();
350
+ setIsDragging(false);
351
+ isDraggingRef.current = false;
352
+ scheduleAutoScrollResume();
353
+ }, [scheduleAutoScrollResume]);
354
+
355
+ const handleTouchEnd = useCallback((_e: globalThis.TouchEvent) => {
356
+ if (!isDraggingRef.current) return;
357
+ setIsDragging(false);
358
+ isDraggingRef.current = false;
359
+ scheduleAutoScrollResume();
360
+ }, [scheduleAutoScrollResume]);
361
+
362
+ const handleTouchMove = useCallback((e: globalThis.TouchEvent) => {
363
+ if (!isDraggingRef.current || e.touches.length !== 1) return;
364
+ e.preventDefault();
365
+ const touch = e.touches[0];
366
+ const deltaY = touch.clientY - lastTouchYRef.current;
367
+ const nextPosition = wrapPosition(positionRef.current + deltaY);
368
+ commitPosition(nextPosition);
369
+ velocityRef.current = deltaY;
370
+ lastTouchYRef.current = touch.clientY;
371
+ }, [commitPosition, wrapPosition]);
372
+
373
+ const handleTouchStart = useCallback((e: globalThis.TouchEvent) => {
374
+ if (!draggable || e.touches.length !== 1) return;
375
+ containerRef.current?.focus();
376
+ setIsDragging(true);
377
+ isDraggingRef.current = true;
378
+ lastTouchYRef.current = e.touches[0].clientY;
379
+ velocityRef.current = 0;
380
+ pauseAutoScroll();
381
+ }, [draggable, pauseAutoScroll]);
199
382
 
383
+ const handleWheel = useCallback((e: globalThis.WheelEvent) => {
384
+ if (!wheelEnabled) return;
385
+ e.preventDefault();
386
+ velocityRef.current -= e.deltaY * WHEEL_VELOCITY_MULTIPLIER;
387
+ velocityRef.current = Math.max(-MAX_VELOCITY, Math.min(MAX_VELOCITY, velocityRef.current));
388
+ pauseAutoScroll();
389
+ scheduleAutoScrollResume();
390
+ }, [pauseAutoScroll, scheduleAutoScrollResume, wheelEnabled]);
391
+
392
+ // Event listener registration
200
393
  useEffect(() => {
201
394
  const currentContainer = containerRef.current;
202
395
  if (!currentContainer) return;
203
396
 
204
- // Create bound event handlers that maintain proper context
205
- const boundHandlers = {
206
- mouseDown: handleMouseDown,
207
- mouseMove: handleMouseMove,
208
- mouseUp: handleMouseUp,
209
- touchStart: handleTouchStart,
210
- touchMove: handleTouchMove,
211
- touchEnd: handleTouchEnd,
212
- wheel: handleWheel
397
+ currentContainer.addEventListener('mousedown', handleMouseDown);
398
+ window.addEventListener('mousemove', handleMouseMove);
399
+ window.addEventListener('mouseup', handleMouseUp);
400
+ currentContainer.addEventListener('touchstart', handleTouchStart, { passive: true });
401
+ window.addEventListener('touchmove', handleTouchMove, { passive: false });
402
+ window.addEventListener('touchend', handleTouchEnd, { passive: true });
403
+ currentContainer.addEventListener('wheel', handleWheel, { passive: false });
404
+
405
+ return () => {
406
+ currentContainer.removeEventListener('mousedown', handleMouseDown);
407
+ window.removeEventListener('mousemove', handleMouseMove);
408
+ window.removeEventListener('mouseup', handleMouseUp);
409
+ currentContainer.removeEventListener('touchstart', handleTouchStart);
410
+ window.removeEventListener('touchmove', handleTouchMove);
411
+ window.removeEventListener('touchend', handleTouchEnd);
412
+ currentContainer.removeEventListener('wheel', handleWheel);
213
413
  };
414
+ }, [handleMouseDown, handleMouseMove, handleMouseUp, handleTouchEnd, handleTouchMove, handleTouchStart, handleWheel]);
415
+
416
+ // Debounced resize handler shared by ResizeObserver and window resize fallback
417
+ const debouncedRecalculate = useCallback(() => {
418
+ if (resizeDebounceTimerRef.current) {
419
+ clearTimeout(resizeDebounceTimerRef.current);
420
+ }
421
+ resizeDebounceTimerRef.current = setTimeout(() => {
422
+ resizeDebounceTimerRef.current = null;
423
+ recalculateLoopPoint();
424
+ }, RESIZE_DEBOUNCE_MS);
425
+ }, [recalculateLoopPoint]);
214
426
 
215
- // Add event listeners
216
- currentContainer.addEventListener('mousedown', boundHandlers.mouseDown);
217
- window.addEventListener('mousemove', boundHandlers.mouseMove);
218
- window.addEventListener('mouseup', boundHandlers.mouseUp);
219
- currentContainer.addEventListener('touchstart', boundHandlers.touchStart, { passive: true });
220
- window.addEventListener('touchmove', boundHandlers.touchMove, { passive: false });
221
- window.addEventListener('touchend', boundHandlers.touchEnd, { passive: true });
222
- currentContainer.addEventListener('wheel', boundHandlers.wheel, { passive: false });
427
+ // Resize listener fallback for browsers without ResizeObserver
428
+ useEffect(() => {
429
+ window.addEventListener('resize', debouncedRecalculate);
223
430
 
224
431
  return () => {
225
- currentContainer.removeEventListener('mousedown', boundHandlers.mouseDown);
226
- window.removeEventListener('mousemove', boundHandlers.mouseMove);
227
- window.removeEventListener('mouseup', boundHandlers.mouseUp);
228
- currentContainer.removeEventListener('touchstart', boundHandlers.touchStart);
229
- window.removeEventListener('touchmove', boundHandlers.touchMove);
230
- window.removeEventListener('touchend', boundHandlers.touchEnd);
231
- currentContainer.removeEventListener('wheel', boundHandlers.wheel);
432
+ window.removeEventListener('resize', debouncedRecalculate);
232
433
  };
233
- }, [handleMouseDown, handleMouseMove, handleMouseUp, handleTouchStart, handleTouchMove, handleTouchEnd, handleWheel]);
434
+ }, [debouncedRecalculate]);
234
435
 
436
+ // ResizeObserver keeps loop measurements in sync with async content changes
235
437
  useEffect(() => {
236
- document.addEventListener('keydown', handleKeyDown);
237
- window.addEventListener('resize', handleResize);
438
+ if (!containerRef.current || typeof ResizeObserver === 'undefined') return;
439
+
440
+ const observer = new ResizeObserver(() => {
441
+ debouncedRecalculate();
442
+ });
443
+ observer.observe(containerRef.current);
238
444
 
239
445
  return () => {
240
- document.removeEventListener('keydown', handleKeyDown);
241
- window.removeEventListener('resize', handleResize);
446
+ observer.disconnect();
447
+ };
448
+ }, [debouncedRecalculate]);
449
+
450
+ // Clean up resize debounce timer on unmount
451
+ useEffect(() => {
452
+ return () => {
453
+ if (resizeDebounceTimerRef.current) {
454
+ clearTimeout(resizeDebounceTimerRef.current);
455
+ resizeDebounceTimerRef.current = null;
456
+ }
242
457
  };
243
- }, [handleKeyDown, handleResize]);
458
+ }, []);
244
459
 
245
- // Tab Visibility Handling
460
+ // Tab visibility handling
246
461
  useEffect(() => {
247
462
  const handleVisibilityChange = () => {
248
463
  setIsTabActive(!document.hidden);
@@ -257,7 +472,14 @@ function ReactSway({ children }: ReactSwayProps) {
257
472
  };
258
473
  }, []);
259
474
 
260
- // Animation Loop
475
+ // Clean up inactivity timer on unmount
476
+ useEffect(() => {
477
+ return () => {
478
+ clearInactivityTimer();
479
+ };
480
+ }, [clearInactivityTimer]);
481
+
482
+ // Animation loop
261
483
  useEffect(() => {
262
484
  if (!isTabActive || isPaused) {
263
485
  if (animationFrameRef.current) {
@@ -267,52 +489,46 @@ function ReactSway({ children }: ReactSwayProps) {
267
489
  return;
268
490
  }
269
491
 
492
+ // Reset so the first frame uses deltaTime=1 instead of a stale timestamp
493
+ lastFrameTimeRef.current = 0;
494
+
495
+ const directionMultiplier = direction === 'down' ? 1 : -1;
496
+
270
497
  const animate = (currentTime: number) => {
271
- let deltaTime = lastFrameTimeRef.current ? (currentTime - lastFrameTimeRef.current) / 16.667 : 1;
498
+ let deltaTime = lastFrameTimeRef.current
499
+ ? (currentTime - lastFrameTimeRef.current) / MS_PER_FRAME_60FPS
500
+ : 1;
272
501
  deltaTime = Math.min(deltaTime, MAX_DELTA_TIME);
273
502
  lastFrameTimeRef.current = currentTime;
274
503
 
275
- setPosition(prevPosition => {
276
- let newPosition = prevPosition;
277
-
278
- // Auto-scroll when enabled
279
- if (autoScrollEnabled && !isDragging && !isPaused) {
280
- newPosition -= SCROLL_SPEED * deltaTime;
281
- }
282
-
283
- return newPosition;
284
- });
285
-
286
- setVelocity(prevVelocity => {
287
- let newVelocity = prevVelocity;
504
+ // Apply velocity damping (skip momentum in reduced-motion mode)
505
+ if (prefersReducedMotion) {
506
+ velocityRef.current = 0;
507
+ } else if (Math.abs(velocityRef.current) > 0.1) {
508
+ velocityRef.current *= Math.pow(normalizedFriction, deltaTime);
509
+ } else {
510
+ velocityRef.current = 0;
511
+ }
288
512
 
289
- if (Math.abs(newVelocity) > 0.1) {
290
- if (!isDragging) {
291
- setPosition(prev => prev + newVelocity * deltaTime);
292
- }
293
- newVelocity *= Math.pow(FRICTION, deltaTime);
294
- } else {
295
- newVelocity = 0;
296
- }
513
+ let nextPosition = positionRef.current;
297
514
 
298
- return newVelocity;
299
- });
515
+ // Calculate effective speed (reduced when user prefers reduced motion)
516
+ const effectiveSpeed = prefersReducedMotion
517
+ ? normalizedSpeed * REDUCED_MOTION_SPEED_FACTOR
518
+ : normalizedSpeed;
300
519
 
301
- // Handle position wrapping
302
- setPosition(prevPosition => {
303
- let newPosition = prevPosition;
520
+ // Auto-scroll when enabled and not dragging
521
+ if (autoScrollRef.current.desired && autoScrollRef.current.active && !isDraggingRef.current) {
522
+ nextPosition += directionMultiplier * effectiveSpeed * deltaTime;
523
+ }
304
524
 
305
- if (loopPoint > 0) {
306
- while (newPosition > 0) {
307
- newPosition -= loopPoint;
308
- }
309
- while (newPosition < -loopPoint * 2) {
310
- newPosition += loopPoint;
311
- }
312
- }
525
+ // Apply velocity momentum from user interaction
526
+ if (!isDraggingRef.current && Math.abs(velocityRef.current) > 0.1) {
527
+ nextPosition += velocityRef.current * deltaTime;
528
+ }
313
529
 
314
- return newPosition;
315
- });
530
+ nextPosition = wrapPosition(nextPosition);
531
+ commitPosition(nextPosition);
316
532
 
317
533
  animationFrameRef.current = requestAnimationFrame(animate);
318
534
  };
@@ -324,11 +540,11 @@ function ReactSway({ children }: ReactSwayProps) {
324
540
  cancelAnimationFrame(animationFrameRef.current);
325
541
  }
326
542
  };
327
- }, [isTabActive, autoScrollEnabled, isDragging, isPaused, loopPoint]);
543
+ }, [commitPosition, direction, isPaused, isTabActive, normalizedFriction, normalizedSpeed, prefersReducedMotion, wrapPosition]);
328
544
 
329
- // Intersection Observer for lazy loading
545
+ // Intersection Observer for lazy visibility detection
330
546
  useEffect(() => {
331
- if (!containerRef.current) return;
547
+ if (!lazy || !containerRef.current || typeof IntersectionObserver === 'undefined') return;
332
548
 
333
549
  const observer = new IntersectionObserver(
334
550
  (entries) => {
@@ -340,8 +556,8 @@ function ReactSway({ children }: ReactSwayProps) {
340
556
  },
341
557
  {
342
558
  root: null,
343
- rootMargin: '100px',
344
- threshold: 0.01,
559
+ rootMargin: lazyRootMargin,
560
+ threshold: lazyThreshold,
345
561
  }
346
562
  );
347
563
 
@@ -352,60 +568,42 @@ function ReactSway({ children }: ReactSwayProps) {
352
568
  items.forEach((item) => observer.unobserve(item));
353
569
  observer.disconnect();
354
570
  };
355
- }, [children, contentHeight]);
356
-
357
- // Apply styles to override conflicting CSS
358
- useEffect(() => {
359
- const originalBodyStyle = {
360
- touchAction: document.body.style.touchAction,
361
- overflow: document.body.style.overflow
362
- };
363
-
364
- // Override body styles that might conflict
365
- document.body.style.touchAction = 'none';
366
- document.body.style.overflow = 'hidden';
367
-
368
- return () => {
369
- // Restore original styles
370
- document.body.style.touchAction = originalBodyStyle.touchAction;
371
- document.body.style.overflow = originalBodyStyle.overflow;
372
- };
373
- }, []);
571
+ }, [children, lazy, lazyRootMargin, lazyThreshold]);
374
572
 
375
573
  return (
376
574
  <div
377
575
  className="react-sway-container scroller-content"
378
576
  ref={containerRef}
379
577
  style={{
380
- transform: `translate3d(0, ${visualPosition}px, 0)`,
381
- cursor: isDragging ? 'grabbing' : 'grab',
578
+ cursor: draggable ? (isDragging ? 'grabbing' : 'grab') : 'default',
579
+ MozUserSelect: 'none',
580
+ msUserSelect: 'none',
581
+ overflow: 'hidden',
582
+ overscrollBehavior: 'contain',
583
+ pointerEvents: 'auto',
382
584
  position: 'absolute',
383
- width: '100%',
384
- willChange: 'transform',
385
- WebkitTransform: 'translateZ(0)',
386
585
  touchAction: 'none',
586
+ transform: 'translate3d(0, 0px, 0)',
387
587
  userSelect: 'none',
388
588
  WebkitUserSelect: 'none',
389
- msUserSelect: 'none',
390
- MozUserSelect: 'none',
391
- overscrollBehavior: 'contain',
392
- // Ensure it's on top and can receive events
393
- pointerEvents: 'auto',
394
- zIndex: 1
589
+ width: '100%',
590
+ willChange: 'transform',
591
+ zIndex: 1,
395
592
  }}
396
- tabIndex={0}
593
+ onKeyDown={keyboard ? handleKeyDown : undefined}
594
+ tabIndex={keyboard ? 0 : undefined}
397
595
  >
398
596
  <div className="content-group original">
399
597
  {children}
400
598
  </div>
401
- <aside className="content-group duplicate" aria-hidden="true" data-duplicate="true" role="presentation">
599
+ <aside aria-hidden="true" className="content-group duplicate" data-duplicate="true" role="presentation">
402
600
  {children}
403
601
  </aside>
404
- <aside className="content-group duplicate" aria-hidden="true" data-duplicate="true" role="presentation">
602
+ <aside aria-hidden="true" className="content-group duplicate" data-duplicate="true" role="presentation">
405
603
  {children}
406
604
  </aside>
407
605
  </div>
408
606
  );
409
607
  }
410
608
 
411
- export default ReactSway;
609
+ export default ReactSway;