react-sway 0.2.7 → 0.3.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,8 +1,14 @@
1
+ /**
2
+ * Core ReactSway component implementing axis-aware infinite scrolling interactions.
3
+ */
1
4
  import { type KeyboardEvent as ReactKeyboardEvent, type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
5
 
3
6
  /** Velocity applied per arrow key press (pixels). */
4
7
  const ARROW_KEY_VELOCITY = 15;
5
8
 
9
+ /** Default edge-hover activation thickness in pixels. */
10
+ const DEFAULT_EDGE_HOVER_SIZE = 96;
11
+
6
12
  /** Default friction coefficient applied to velocity each frame. */
7
13
  const DEFAULT_FRICTION = 0.95;
8
14
 
@@ -19,7 +25,7 @@ const DEFAULT_RESUME_DELAY = 2000;
19
25
  const DEFAULT_SPEED = 0.5;
20
26
 
21
27
  /** Number of stacked content groups used to build the seamless loop. */
22
- const LOOP_SEGMENTS = 3;
28
+ const MIN_LOOP_SEGMENTS = 3;
23
29
 
24
30
  /** Maximum deltaTime cap to prevent physics instability during frame drops. */
25
31
  const MAX_DELTA_TIME = 3;
@@ -57,11 +63,21 @@ const WHEEL_IMMEDIATE_DELTA_FACTOR = 0.14;
57
63
  /** Legacy wheelDelta magnitude that usually represents one physical wheel notch. */
58
64
  const WHEEL_LEGACY_NOTCH_DELTA = 120;
59
65
 
66
+ /** Minimum touch movement before a gesture is classified by axis. */
67
+ const TOUCH_AXIS_LOCK_THRESHOLD_PX = 8;
68
+
60
69
  interface WheelEventWithLegacyDelta extends globalThis.WheelEvent {
61
70
  wheelDelta?: number;
62
71
  wheelDeltaY?: number;
63
72
  }
64
73
 
74
+ export type SwayAxis = 'horizontal' | 'vertical';
75
+ export type SwayDirection = 'down' | 'left' | 'right' | 'up';
76
+ export type SwayWheelMode = 'axis' | 'capture';
77
+
78
+ type WheelDeltaAxis = 'x' | 'y';
79
+ type TouchInteractionState = 'active' | 'idle' | 'ignored' | 'pending';
80
+
65
81
  function getLegacyWheelPixelDeltaY(event: globalThis.WheelEvent) {
66
82
  const { wheelDelta, wheelDeltaY } = event as WheelEventWithLegacyDelta;
67
83
  const legacyWheelDelta = typeof wheelDeltaY === 'number' ? wheelDeltaY : wheelDelta;
@@ -77,49 +93,100 @@ function getLegacyWheelPixelDeltaY(event: globalThis.WheelEvent) {
77
93
  * Converts wheel deltas to pixels so mouse wheels, touchpads, and page-wheel
78
94
  * devices feed the same Sway velocity system.
79
95
  */
80
- function normalizeWheelDeltaY(event: globalThis.WheelEvent, container: HTMLElement) {
96
+ function normalizeWheelDelta(event: globalThis.WheelEvent, container: HTMLElement, axis: WheelDeltaAxis) {
97
+ const rawDelta = axis === 'x' ? event.deltaX : event.deltaY;
98
+
81
99
  if (event.deltaMode === WHEEL_MODE_LINE) {
82
100
  const computedLineHeight = Number.parseFloat(window.getComputedStyle(container).lineHeight);
83
101
  const lineHeight = Number.isFinite(computedLineHeight)
84
102
  ? computedLineHeight
85
103
  : WHEEL_LINE_HEIGHT_FALLBACK_PX;
86
104
 
87
- return event.deltaY * lineHeight;
105
+ return rawDelta * lineHeight;
88
106
  }
89
107
 
90
108
  if (event.deltaMode === WHEEL_MODE_PAGE) {
91
- const pageHeight = Math.max(container.clientHeight, window.innerHeight, 1);
109
+ const pageSize = axis === 'x'
110
+ ? Math.max(container.clientWidth, window.innerWidth, 1)
111
+ : Math.max(container.clientHeight, window.innerHeight, 1);
92
112
 
93
- return event.deltaY * pageHeight;
113
+ return rawDelta * pageSize;
94
114
  }
95
115
 
116
+ if (axis === 'x') return rawDelta;
117
+
96
118
  const legacyWheelPixelDeltaY = getLegacyWheelPixelDeltaY(event);
97
119
 
98
120
  if (
99
- Math.abs(event.deltaY) < WHEEL_PIXEL_MIN_DELTA_PX &&
121
+ Math.abs(rawDelta) < WHEEL_PIXEL_MIN_DELTA_PX &&
100
122
  Math.abs(legacyWheelPixelDeltaY) >= WHEEL_PIXEL_MIN_DELTA_PX
101
123
  ) {
102
124
  return legacyWheelPixelDeltaY;
103
125
  }
104
126
 
105
- return event.deltaY;
127
+ return rawDelta;
128
+ }
129
+
130
+ function getOwnedWheelDelta(
131
+ event: globalThis.WheelEvent,
132
+ container: HTMLElement,
133
+ isHorizontal: boolean,
134
+ wheelMode: SwayWheelMode,
135
+ ) {
136
+ // The ownership function is intentionally partial: null means native scroll
137
+ // chaining remains the browser's responsibility for cross-axis gestures.
138
+ const deltaX = normalizeWheelDelta(event, container, 'x');
139
+ const deltaY = normalizeWheelDelta(event, container, 'y');
140
+ const absDeltaX = Math.abs(deltaX);
141
+ const absDeltaY = Math.abs(deltaY);
142
+
143
+ if (isHorizontal) {
144
+ if (wheelMode === 'axis' && absDeltaX <= absDeltaY && !event.shiftKey) return null;
145
+ return absDeltaX > absDeltaY ? deltaX : deltaY;
146
+ }
147
+
148
+ if (wheelMode === 'axis' && absDeltaX > absDeltaY) return null;
149
+ return deltaY;
150
+ }
151
+
152
+ function getVisibleEdgeInterval(rect: DOMRect, isHorizontal: boolean) {
153
+ const rectEnd = isHorizontal ? rect.right : rect.bottom;
154
+ const rectStart = isHorizontal ? rect.left : rect.top;
155
+ const viewportEnd = isHorizontal ? window.innerWidth : window.innerHeight;
156
+
157
+ const end = Math.min(rectEnd, viewportEnd);
158
+ const start = Math.max(rectStart, 0);
159
+
160
+ if (end <= start) return null;
161
+
162
+ return {
163
+ end,
164
+ size: end - start,
165
+ start,
166
+ };
106
167
  }
107
168
 
108
169
  /**
109
170
  * Props for the ReactSway infinite scrolling component.
110
171
  */
111
172
  export interface ReactSwayProps {
173
+ /** Scroll axis. Horizontal directions imply `horizontal` when omitted. @default 'vertical' */
174
+ axis?: SwayAxis;
112
175
  /** Enable/disable auto-scrolling. @default true */
113
176
  autoScroll?: boolean;
114
177
  /** Content elements to render in the infinite scroll container. */
115
178
  children: ReactNode;
116
179
  /** Auto-scroll direction. @default 'up' */
117
- direction?: 'down' | 'up';
180
+ direction?: SwayDirection;
118
181
  /** Enable mouse/touch drag interaction. @default true */
119
182
  draggable?: boolean;
183
+ /** Only auto-scroll while the pointer hovers an axis boundary. @default false */
184
+ edgeHoverScroll?: boolean;
185
+ /** Edge-hover activation thickness in pixels. @default 96 */
186
+ edgeHoverSize?: number;
120
187
  /** Momentum decay coefficient (0-1, lower = more friction). @default 0.95 */
121
188
  friction?: number;
122
- /** Enable keyboard controls (Space, ArrowUp/Down, Home/End). @default true */
189
+ /** Enable keyboard controls (Space, Arrow keys, Home/End). @default true */
123
190
  keyboard?: boolean;
124
191
  /** Enable lazy visibility detection via IntersectionObserver. @default true */
125
192
  lazy?: boolean;
@@ -141,13 +208,24 @@ export interface ReactSwayProps {
141
208
  speed?: number;
142
209
  /** Enable mouse wheel scrolling. @default true */
143
210
  wheelEnabled?: boolean;
211
+ /**
212
+ * Defines which wheel gestures ReactSway may cancel.
213
+ *
214
+ * `axis` only consumes wheel gestures aligned with the Sway axis, letting
215
+ * cross-axis page scroll pass through. `capture` preserves legacy behavior
216
+ * by consuming every wheel event that reaches the component.
217
+ *
218
+ * @default 'axis'
219
+ */
220
+ wheelMode?: SwayWheelMode;
144
221
  }
145
222
 
146
223
  /**
147
224
  * A smooth, infinite scrolling container component.
148
225
  *
149
226
  * Renders children in a continuously looping scroll area with support for
150
- * auto-scrolling, mouse drag, touch swipe, wheel, and keyboard interactions.
227
+ * auto-scrolling, mouse drag, touch swipe, wheel, keyboard interactions,
228
+ * and optional edge-hover scrolling.
151
229
  * Content is duplicated to create a seamless loop effect. Duplicate content
152
230
  * is wrapped in `<aside>` elements with `aria-hidden="true"` for accessibility.
153
231
  *
@@ -163,10 +241,13 @@ export interface ReactSwayProps {
163
241
  * ```
164
242
  */
165
243
  function ReactSway({
244
+ axis,
166
245
  autoScroll = true,
167
246
  children,
168
247
  direction = 'up',
169
248
  draggable = true,
249
+ edgeHoverScroll = false,
250
+ edgeHoverSize = DEFAULT_EDGE_HOVER_SIZE,
170
251
  friction = DEFAULT_FRICTION,
171
252
  keyboard = true,
172
253
  lazy = true,
@@ -179,7 +260,17 @@ function ReactSway({
179
260
  resumeDelay = DEFAULT_RESUME_DELAY,
180
261
  speed = DEFAULT_SPEED,
181
262
  wheelEnabled = true,
263
+ wheelMode = 'axis',
182
264
  }: ReactSwayProps) {
265
+ const scrollAxis = useMemo<SwayAxis>(
266
+ () => axis ?? (direction === 'left' || direction === 'right' ? 'horizontal' : 'vertical'),
267
+ [axis, direction],
268
+ );
269
+ const isHorizontal = scrollAxis === 'horizontal';
270
+ const normalizedEdgeHoverSize = useMemo(
271
+ () => (Number.isFinite(edgeHoverSize) ? Math.max(1, edgeHoverSize) : DEFAULT_EDGE_HOVER_SIZE),
272
+ [edgeHoverSize],
273
+ );
183
274
  const normalizedFriction = useMemo(
184
275
  () => (Number.isFinite(friction) ? Math.min(Math.max(friction, 0), 1) : DEFAULT_FRICTION),
185
276
  [friction],
@@ -192,11 +283,19 @@ function ReactSway({
192
283
  () => (Number.isFinite(speed) ? Math.max(0, speed) : DEFAULT_SPEED),
193
284
  [speed],
194
285
  );
286
+ const touchAction = useMemo(() => {
287
+ if (!draggable) return 'auto';
288
+ return isHorizontal ? 'pan-y' : 'pan-x';
289
+ }, [draggable, isHorizontal]);
290
+ // Containment is sound only when ReactSway owns every wheel event it receives;
291
+ // otherwise cross-axis and disabled-wheel page scroll must remain chainable.
292
+ const overscrollBehavior = wheelEnabled && wheelMode === 'capture' ? 'contain' : 'auto';
195
293
 
196
294
  const [isDragging, setIsDragging] = useState(false);
197
295
  const [isPaused, setIsPaused] = useState(false);
198
296
  const [isTabActive, setIsTabActive] = useState(true);
199
297
  const [loopPoint, setLoopPoint] = useState(0);
298
+ const [loopSegmentCount, setLoopSegmentCount] = useState(MIN_LOOP_SEGMENTS);
200
299
  const [prefersReducedMotion, setPrefersReducedMotion] = useState(() => {
201
300
  if (typeof window === 'undefined') return false;
202
301
  return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
@@ -209,14 +308,21 @@ function ReactSway({
209
308
  const isDraggingRef = useRef(false);
210
309
  const isPausedRef = useRef(false);
211
310
  const lastFrameTimeRef = useRef(0);
311
+ const lastMouseXRef = useRef(0);
212
312
  const lastMouseYRef = useRef(0);
313
+ const lastTouchXRef = useRef(0);
213
314
  const lastTouchYRef = useRef(0);
214
315
  const loopPointRef = useRef(0);
316
+ const edgeHoverDirectionRef = useRef(0);
215
317
  const onPauseRef = useRef(onPause);
216
318
  const onResumeRef = useRef(onResume);
217
319
  const onScrollRef = useRef(onScroll);
320
+ const originalGroupRef = useRef<HTMLDivElement>(null);
218
321
  const positionRef = useRef(0);
219
322
  const resizeDebounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
323
+ const touchInteractionStateRef = useRef<TouchInteractionState>('idle');
324
+ const touchStartXRef = useRef(0);
325
+ const touchStartYRef = useRef(0);
220
326
  const velocityRef = useRef(0);
221
327
 
222
328
  // Keep callback refs in sync without triggering re-renders
@@ -252,8 +358,10 @@ function ReactSway({
252
358
  if (visualPosition > 0 && currentLoopPoint > 0) {
253
359
  visualPosition -= currentLoopPoint;
254
360
  }
255
- containerRef.current.style.transform = `translate3d(0, ${visualPosition}px, 0)`;
256
- }, []);
361
+ containerRef.current.style.transform = isHorizontal
362
+ ? `translate3d(${visualPosition}px, 0, 0)`
363
+ : `translate3d(0, ${visualPosition}px, 0)`;
364
+ }, [isHorizontal]);
257
365
 
258
366
  const commitPosition = useCallback((nextPosition: number) => {
259
367
  if (positionRef.current === nextPosition) return;
@@ -277,15 +385,26 @@ function ReactSway({
277
385
  }, []);
278
386
 
279
387
  const recalculateLoopPoint = useCallback(() => {
280
- if (!containerRef.current) return;
281
- const currentContentHeight = containerRef.current.scrollHeight;
282
- if (currentContentHeight <= 0) return;
283
-
284
- const nextLoopPoint = currentContentHeight / LOOP_SEGMENTS;
388
+ if (!containerRef.current || !originalGroupRef.current) return;
389
+ const originalContentSize = isHorizontal
390
+ ? originalGroupRef.current.scrollWidth
391
+ : originalGroupRef.current.scrollHeight;
392
+ if (originalContentSize <= 0) return;
393
+
394
+ const viewport = containerRef.current.parentElement ?? containerRef.current;
395
+ const viewportSize = isHorizontal
396
+ ? Math.max(viewport.clientWidth, 1)
397
+ : Math.max(viewport.clientHeight, 1);
398
+ const nextLoopSegmentCount = Math.max(MIN_LOOP_SEGMENTS, Math.ceil(viewportSize / originalContentSize) + 2);
399
+ const nextLoopPoint = originalContentSize;
400
+
401
+ setLoopSegmentCount((previousLoopSegmentCount) =>
402
+ previousLoopSegmentCount === nextLoopSegmentCount ? previousLoopSegmentCount : nextLoopSegmentCount
403
+ );
285
404
  loopPointRef.current = nextLoopPoint;
286
405
  setLoopPoint((previousLoopPoint) => (Math.abs(previousLoopPoint - nextLoopPoint) < 0.5 ? previousLoopPoint : nextLoopPoint));
287
406
  renderPosition(positionRef.current);
288
- }, [renderPosition]);
407
+ }, [isHorizontal, renderPosition]);
289
408
 
290
409
  // Sync autoScroll prop changes with internal state
291
410
  useEffect(() => {
@@ -357,17 +476,33 @@ function ReactSway({
357
476
  togglePause();
358
477
  break;
359
478
  case 'ArrowDown':
479
+ if (isHorizontal) break;
360
480
  e.preventDefault();
361
481
  velocityRef.current -= ARROW_KEY_VELOCITY;
362
482
  pauseAutoScroll();
363
483
  scheduleAutoScrollResume();
364
484
  break;
365
485
  case 'ArrowUp':
486
+ if (isHorizontal) break;
487
+ e.preventDefault();
488
+ velocityRef.current += ARROW_KEY_VELOCITY;
489
+ pauseAutoScroll();
490
+ scheduleAutoScrollResume();
491
+ break;
492
+ case 'ArrowLeft':
493
+ if (!isHorizontal) break;
366
494
  e.preventDefault();
367
495
  velocityRef.current += ARROW_KEY_VELOCITY;
368
496
  pauseAutoScroll();
369
497
  scheduleAutoScrollResume();
370
498
  break;
499
+ case 'ArrowRight':
500
+ if (!isHorizontal) break;
501
+ e.preventDefault();
502
+ velocityRef.current -= ARROW_KEY_VELOCITY;
503
+ pauseAutoScroll();
504
+ scheduleAutoScrollResume();
505
+ break;
371
506
  case 'End':
372
507
  e.preventDefault();
373
508
  if (loopPointRef.current > 0) {
@@ -387,7 +522,7 @@ function ReactSway({
387
522
  default:
388
523
  break;
389
524
  }
390
- }, [commitPosition, keyboard, pauseAutoScroll, scheduleAutoScrollResume, togglePause]);
525
+ }, [commitPosition, isHorizontal, keyboard, pauseAutoScroll, scheduleAutoScrollResume, togglePause]);
391
526
 
392
527
  const handleMouseDown = useCallback((e: globalThis.MouseEvent) => {
393
528
  if (!draggable) return;
@@ -395,6 +530,7 @@ function ReactSway({
395
530
  containerRef.current?.focus();
396
531
  setIsDragging(true);
397
532
  isDraggingRef.current = true;
533
+ lastMouseXRef.current = e.clientX;
398
534
  lastMouseYRef.current = e.clientY;
399
535
  velocityRef.current = 0;
400
536
  pauseAutoScroll();
@@ -403,12 +539,13 @@ function ReactSway({
403
539
  const handleMouseMove = useCallback((e: globalThis.MouseEvent) => {
404
540
  if (!isDraggingRef.current) return;
405
541
  e.preventDefault();
406
- const deltaY = e.clientY - lastMouseYRef.current;
407
- const nextPosition = wrapPosition(positionRef.current + deltaY);
542
+ const delta = isHorizontal ? e.clientX - lastMouseXRef.current : e.clientY - lastMouseYRef.current;
543
+ const nextPosition = wrapPosition(positionRef.current + delta);
408
544
  commitPosition(nextPosition);
409
- velocityRef.current = deltaY;
545
+ velocityRef.current = delta;
546
+ lastMouseXRef.current = e.clientX;
410
547
  lastMouseYRef.current = e.clientY;
411
- }, [commitPosition, wrapPosition]);
548
+ }, [commitPosition, isHorizontal, wrapPosition]);
412
549
 
413
550
  const handleMouseUp = useCallback((e: globalThis.MouseEvent) => {
414
551
  if (!isDraggingRef.current) return;
@@ -419,48 +556,113 @@ function ReactSway({
419
556
  }, [scheduleAutoScrollResume]);
420
557
 
421
558
  const handleTouchEnd = useCallback((_e: globalThis.TouchEvent) => {
422
- if (!isDraggingRef.current) return;
423
- setIsDragging(false);
424
- isDraggingRef.current = false;
425
- scheduleAutoScrollResume();
559
+ const wasActive = touchInteractionStateRef.current === 'active';
560
+
561
+ touchInteractionStateRef.current = 'idle';
562
+ if (isDraggingRef.current) {
563
+ setIsDragging(false);
564
+ isDraggingRef.current = false;
565
+ }
566
+
567
+ if (wasActive) {
568
+ scheduleAutoScrollResume();
569
+ }
426
570
  }, [scheduleAutoScrollResume]);
427
571
 
428
572
  const handleTouchMove = useCallback((e: globalThis.TouchEvent) => {
429
- if (!isDraggingRef.current || e.touches.length !== 1) return;
430
- e.preventDefault();
573
+ if (touchInteractionStateRef.current === 'idle' || touchInteractionStateRef.current === 'ignored') return;
574
+ if (e.touches.length !== 1) return;
575
+
431
576
  const touch = e.touches[0];
432
- const deltaY = touch.clientY - lastTouchYRef.current;
433
- const nextPosition = wrapPosition(positionRef.current + deltaY);
577
+
578
+ if (touchInteractionStateRef.current === 'pending') {
579
+ const totalDeltaX = touch.clientX - touchStartXRef.current;
580
+ const totalDeltaY = touch.clientY - touchStartYRef.current;
581
+ const primaryDelta = isHorizontal ? Math.abs(totalDeltaX) : Math.abs(totalDeltaY);
582
+ const crossDelta = isHorizontal ? Math.abs(totalDeltaY) : Math.abs(totalDeltaX);
583
+
584
+ if (Math.max(primaryDelta, crossDelta) < TOUCH_AXIS_LOCK_THRESHOLD_PX) return;
585
+
586
+ if (crossDelta > primaryDelta) {
587
+ touchInteractionStateRef.current = 'ignored';
588
+ return;
589
+ }
590
+
591
+ touchInteractionStateRef.current = 'active';
592
+ setIsDragging(true);
593
+ isDraggingRef.current = true;
594
+ pauseAutoScroll();
595
+ }
596
+
597
+ e.preventDefault();
598
+ const delta = isHorizontal ? touch.clientX - lastTouchXRef.current : touch.clientY - lastTouchYRef.current;
599
+ const nextPosition = wrapPosition(positionRef.current + delta);
434
600
  commitPosition(nextPosition);
435
- velocityRef.current = deltaY;
601
+ velocityRef.current = delta;
602
+ lastTouchXRef.current = touch.clientX;
436
603
  lastTouchYRef.current = touch.clientY;
437
- }, [commitPosition, wrapPosition]);
604
+ }, [commitPosition, isHorizontal, pauseAutoScroll, wrapPosition]);
438
605
 
439
606
  const handleTouchStart = useCallback((e: globalThis.TouchEvent) => {
440
607
  if (!draggable || e.touches.length !== 1) return;
441
608
  containerRef.current?.focus();
442
- setIsDragging(true);
443
- isDraggingRef.current = true;
609
+ touchInteractionStateRef.current = 'pending';
610
+ lastTouchXRef.current = e.touches[0].clientX;
444
611
  lastTouchYRef.current = e.touches[0].clientY;
612
+ touchStartXRef.current = e.touches[0].clientX;
613
+ touchStartYRef.current = e.touches[0].clientY;
445
614
  velocityRef.current = 0;
446
- pauseAutoScroll();
447
- }, [draggable, pauseAutoScroll]);
615
+ }, [draggable]);
448
616
 
449
617
  const handleWheel = useCallback((e: globalThis.WheelEvent) => {
450
618
  if (!wheelEnabled) return;
451
619
  const currentContainer = e.currentTarget instanceof HTMLElement ? e.currentTarget : containerRef.current;
452
620
  if (!currentContainer) return;
453
621
 
622
+ const ownedDelta = getOwnedWheelDelta(e, currentContainer, isHorizontal, wheelMode);
623
+ if (ownedDelta === null) return;
624
+
454
625
  e.preventDefault();
455
- const normalizedDeltaY = normalizeWheelDeltaY(e, currentContainer);
456
- const wheelDelta = -normalizedDeltaY;
626
+ const wheelDelta = -ownedDelta;
457
627
  const nextPosition = wrapPosition(positionRef.current + wheelDelta * WHEEL_IMMEDIATE_DELTA_FACTOR);
458
628
  commitPosition(nextPosition);
459
629
  velocityRef.current += wheelDelta * WHEEL_VELOCITY_MULTIPLIER;
460
630
  velocityRef.current = Math.max(-MAX_VELOCITY, Math.min(MAX_VELOCITY, velocityRef.current));
461
631
  pauseAutoScroll();
462
632
  scheduleAutoScrollResume();
463
- }, [commitPosition, pauseAutoScroll, scheduleAutoScrollResume, wheelEnabled, wrapPosition]);
633
+ }, [commitPosition, isHorizontal, pauseAutoScroll, scheduleAutoScrollResume, wheelEnabled, wheelMode, wrapPosition]);
634
+
635
+ const handleEdgeHoverMove = useCallback((e: globalThis.MouseEvent) => {
636
+ if (!edgeHoverScroll) return;
637
+ const viewport = containerRef.current?.parentElement ?? containerRef.current;
638
+ if (!viewport) return;
639
+
640
+ const rect = viewport.getBoundingClientRect();
641
+ const visibleInterval = getVisibleEdgeInterval(rect, isHorizontal);
642
+ if (!visibleInterval) {
643
+ edgeHoverDirectionRef.current = 0;
644
+ return;
645
+ }
646
+
647
+ const pointerPosition = isHorizontal ? e.clientX : e.clientY;
648
+ const effectiveEdgeHoverSize = Math.min(normalizedEdgeHoverSize, visibleInterval.size / 2);
649
+
650
+ if (pointerPosition <= visibleInterval.start + effectiveEdgeHoverSize) {
651
+ edgeHoverDirectionRef.current = 1;
652
+ return;
653
+ }
654
+
655
+ if (pointerPosition >= visibleInterval.end - effectiveEdgeHoverSize) {
656
+ edgeHoverDirectionRef.current = -1;
657
+ return;
658
+ }
659
+
660
+ edgeHoverDirectionRef.current = 0;
661
+ }, [edgeHoverScroll, isHorizontal, normalizedEdgeHoverSize]);
662
+
663
+ const handleEdgeHoverLeave = useCallback(() => {
664
+ edgeHoverDirectionRef.current = 0;
665
+ }, []);
464
666
 
465
667
  // Event listener registration
466
668
  useEffect(() => {
@@ -470,21 +672,34 @@ function ReactSway({
470
672
  currentContainer.addEventListener('mousedown', handleMouseDown);
471
673
  window.addEventListener('mousemove', handleMouseMove);
472
674
  window.addEventListener('mouseup', handleMouseUp);
473
- currentContainer.addEventListener('touchstart', handleTouchStart, { passive: true });
474
- window.addEventListener('touchmove', handleTouchMove, { passive: false });
475
- window.addEventListener('touchend', handleTouchEnd, { passive: true });
476
- currentContainer.addEventListener('wheel', handleWheel, { passive: false });
675
+ if (draggable) {
676
+ currentContainer.addEventListener('touchstart', handleTouchStart, { passive: true });
677
+ window.addEventListener('touchmove', handleTouchMove, { passive: false });
678
+ window.addEventListener('touchend', handleTouchEnd, { passive: true });
679
+ }
680
+ if (wheelEnabled) {
681
+ currentContainer.addEventListener('wheel', handleWheel, { passive: false });
682
+ }
683
+ const edgeHoverTarget = currentContainer.parentElement ?? currentContainer;
684
+ edgeHoverTarget.addEventListener('mousemove', handleEdgeHoverMove);
685
+ edgeHoverTarget.addEventListener('mouseleave', handleEdgeHoverLeave);
477
686
 
478
687
  return () => {
479
688
  currentContainer.removeEventListener('mousedown', handleMouseDown);
480
689
  window.removeEventListener('mousemove', handleMouseMove);
481
690
  window.removeEventListener('mouseup', handleMouseUp);
482
- currentContainer.removeEventListener('touchstart', handleTouchStart);
483
- window.removeEventListener('touchmove', handleTouchMove);
484
- window.removeEventListener('touchend', handleTouchEnd);
485
- currentContainer.removeEventListener('wheel', handleWheel);
691
+ if (draggable) {
692
+ currentContainer.removeEventListener('touchstart', handleTouchStart);
693
+ window.removeEventListener('touchmove', handleTouchMove);
694
+ window.removeEventListener('touchend', handleTouchEnd);
695
+ }
696
+ if (wheelEnabled) {
697
+ currentContainer.removeEventListener('wheel', handleWheel);
698
+ }
699
+ edgeHoverTarget.removeEventListener('mousemove', handleEdgeHoverMove);
700
+ edgeHoverTarget.removeEventListener('mouseleave', handleEdgeHoverLeave);
486
701
  };
487
- }, [handleMouseDown, handleMouseMove, handleMouseUp, handleTouchEnd, handleTouchMove, handleTouchStart, handleWheel]);
702
+ }, [draggable, handleEdgeHoverLeave, handleEdgeHoverMove, handleMouseDown, handleMouseMove, handleMouseUp, handleTouchEnd, handleTouchMove, handleTouchStart, handleWheel, wheelEnabled]);
488
703
 
489
704
  // Debounced resize handler shared by ResizeObserver and window resize fallback
490
705
  const debouncedRecalculate = useCallback(() => {
@@ -565,7 +780,7 @@ function ReactSway({
565
780
  // Reset so the first frame uses deltaTime=1 instead of a stale timestamp
566
781
  lastFrameTimeRef.current = 0;
567
782
 
568
- const directionMultiplier = direction === 'down' ? 1 : -1;
783
+ const directionMultiplier = direction === 'down' || direction === 'right' ? 1 : -1;
569
784
 
570
785
  const animate = (currentTime: number) => {
571
786
  let deltaTime = lastFrameTimeRef.current
@@ -590,9 +805,16 @@ function ReactSway({
590
805
  ? normalizedSpeed * REDUCED_MOTION_SPEED_FACTOR
591
806
  : normalizedSpeed;
592
807
 
808
+ const edgeHoverMultiplier = edgeHoverScroll ? edgeHoverDirectionRef.current : directionMultiplier;
809
+
593
810
  // Auto-scroll when enabled and not dragging
594
- if (autoScrollRef.current.desired && autoScrollRef.current.active && !isDraggingRef.current) {
595
- nextPosition += directionMultiplier * effectiveSpeed * deltaTime;
811
+ if (
812
+ autoScrollRef.current.desired &&
813
+ autoScrollRef.current.active &&
814
+ !isDraggingRef.current &&
815
+ (!edgeHoverScroll || edgeHoverMultiplier !== 0)
816
+ ) {
817
+ nextPosition += edgeHoverMultiplier * effectiveSpeed * deltaTime;
596
818
  }
597
819
 
598
820
  // Apply velocity momentum from user interaction
@@ -613,7 +835,7 @@ function ReactSway({
613
835
  cancelAnimationFrame(animationFrameRef.current);
614
836
  }
615
837
  };
616
- }, [commitPosition, direction, isPaused, isTabActive, normalizedFriction, normalizedSpeed, prefersReducedMotion, wrapPosition]);
838
+ }, [commitPosition, direction, edgeHoverScroll, isHorizontal, isPaused, isTabActive, normalizedFriction, normalizedSpeed, prefersReducedMotion, wrapPosition]);
617
839
 
618
840
  // Intersection Observer for lazy visibility detection
619
841
  useEffect(() => {
@@ -649,32 +871,42 @@ function ReactSway({
649
871
  ref={containerRef}
650
872
  style={{
651
873
  cursor: draggable ? (isDragging ? 'grabbing' : 'grab') : 'default',
874
+ display: isHorizontal ? 'flex' : undefined,
875
+ flexDirection: isHorizontal ? 'row' : undefined,
876
+ height: isHorizontal ? '100%' : 'max-content',
652
877
  MozUserSelect: 'none',
653
878
  msUserSelect: 'none',
879
+ minHeight: isHorizontal ? undefined : '100%',
654
880
  overflow: 'hidden',
655
- overscrollBehavior: 'contain',
881
+ overscrollBehavior,
656
882
  pointerEvents: 'auto',
657
883
  position: 'absolute',
658
- touchAction: 'none',
659
- transform: 'translate3d(0, 0px, 0)',
884
+ touchAction,
885
+ transform: 'translate3d(0, 0, 0)',
660
886
  userSelect: 'none',
661
887
  WebkitUserSelect: 'none',
662
- width: '100%',
888
+ width: isHorizontal ? 'max-content' : '100%',
663
889
  willChange: 'transform',
664
890
  zIndex: 1,
665
891
  }}
666
892
  onKeyDown={keyboard ? handleKeyDown : undefined}
667
893
  tabIndex={keyboard ? 0 : undefined}
668
894
  >
669
- <div className="content-group original">
895
+ <div className="content-group original" ref={originalGroupRef} style={isHorizontal ? { display: 'flex', flex: '0 0 auto' } : undefined}>
670
896
  {children}
671
897
  </div>
672
- <aside aria-hidden="true" className="content-group duplicate" data-duplicate="true" role="presentation">
673
- {children}
674
- </aside>
675
- <aside aria-hidden="true" className="content-group duplicate" data-duplicate="true" role="presentation">
676
- {children}
677
- </aside>
898
+ {Array.from({ length: loopSegmentCount - 1 }, (_, duplicateIndex) => (
899
+ <aside
900
+ aria-hidden="true"
901
+ className="content-group duplicate"
902
+ data-duplicate="true"
903
+ key={`duplicate-${duplicateIndex}`}
904
+ role="presentation"
905
+ style={isHorizontal ? { display: 'flex', flex: '0 0 auto' } : undefined}
906
+ >
907
+ {children}
908
+ </aside>
909
+ ))}
678
910
  </div>
679
911
  );
680
912
  }
package/src/index.ts CHANGED
@@ -2,4 +2,4 @@
2
2
  * Public package entrypoint for ReactSway exports.
3
3
  */
4
4
  export { default as ReactSway } from './ReactSway';
5
- export type { ReactSwayProps } from './ReactSway';
5
+ export type { ReactSwayProps, SwayAxis, SwayDirection, SwayWheelMode } from './ReactSway';