react-sway 0.4.2 → 0.5.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/dist/index.js CHANGED
@@ -1,9 +1,25 @@
1
- import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from "react";
2
- import { jsx, jsxs } from "react/jsx-runtime";
3
- //#region src/edge-hover-velocity.ts
4
- /**
5
- * Defines the bounded spatial mapping from edge-hover depth to Sway velocity.
6
- */
1
+ import React, { Children, forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState, version } from "react";
2
+ // src/depth-position.ts
3
+ var DEFAULT_DEPTH_PERSPECTIVE = 800;
4
+ var DEFAULT_DEPTH_SPACING = 420;
5
+ /** Bounds pixel lengths so periods and fade denominators remain finite and nonzero. */
6
+ function normalizeDepthLength(value, fallback) {
7
+ return Number.isFinite(value) && value > 0 ? Math.min(Number.MAX_SAFE_INTEGER, Math.max(1, value)) : fallback;
8
+ }
9
+ function getDepthItemState(position, index, count, perspective = 800, spacing = 420) {
10
+ if (count <= 0) return {
11
+ opacity: 0,
12
+ z: 0
13
+ };
14
+ const period = count * spacing;
15
+ const phase = ((index * spacing + position) % period + period) % period;
16
+ const fade = Math.min(1, Math.min(phase, period - phase) / (spacing * .45));
17
+ return {
18
+ opacity: fade * fade * (3 - 2 * fade),
19
+ z: perspective * .7 - phase
20
+ };
21
+ }
22
+ // src/edge-hover-velocity.ts
7
23
  /** Default edge-hover activation thickness in pixels. */
8
24
  var DEFAULT_EDGE_HOVER_SIZE = 96;
9
25
  /** Default maximum edge-hover speed relative to the configured base speed. */
@@ -49,10 +65,10 @@ function getEdgeHoverVelocityScale(intensity, speedMultiplier = 6) {
49
65
  if (depth < EDGE_HOVER_ENTRY_FEATHER_INTENSITY) return direction * smoothUnitStep(depth / EDGE_HOVER_ENTRY_FEATHER_INTENSITY);
50
66
  const physicalEdgeScale = Math.min(EDGE_HOVER_PHYSICAL_EDGE_SPEED_MULTIPLIER, maximumScale);
51
67
  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);
68
+ const physicalEdgeProgress = (depth - EDGE_HOVER_ENTRY_FEATHER_INTENSITY) / .42;
53
69
  return direction * (1 + (physicalEdgeScale - 1) * smoothUnitStep(physicalEdgeProgress));
54
70
  }
55
- const beyondEdgeProgress = (depth - EDGE_HOVER_PHYSICAL_EDGE_INTENSITY) / (1 - EDGE_HOVER_PHYSICAL_EDGE_INTENSITY);
71
+ const beyondEdgeProgress = (depth - EDGE_HOVER_PHYSICAL_EDGE_INTENSITY) / .5;
56
72
  return direction * (physicalEdgeScale + (maximumScale - physicalEdgeScale) * smoothUnitStep(beyondEdgeProgress));
57
73
  }
58
74
  /**
@@ -78,9 +94,7 @@ function getEdgeHoverIntensity(pointerPosition, visibleInterval, edgeHoverSize)
78
94
  }
79
95
  return 0;
80
96
  }
81
- //#endregion
82
- //#region src/wheel-input.ts
83
- /** Fallback pixel height for wheel events reported in line units. */
97
+ // src/wheel-input.ts
84
98
  var WHEEL_LINE_HEIGHT_FALLBACK_PX = 16;
85
99
  /** WheelEvent deltaMode value for line-based deltas. */
86
100
  var WHEEL_MODE_LINE = 1;
@@ -114,8 +128,10 @@ function normalizeWheelDelta(event, container, axis) {
114
128
  * the gesture. This partial function is the wheel-ownership boundary.
115
129
  */
116
130
  function getOwnedWheelDelta(event, container, isHorizontal, wheelMode) {
131
+ if (event.ctrlKey) return null;
117
132
  const deltaX = normalizeWheelDelta(event, container, "x");
118
133
  const deltaY = normalizeWheelDelta(event, container, "y");
134
+ if (!Number.isFinite(deltaX) || !Number.isFinite(deltaY) || deltaX === 0 && deltaY === 0) return null;
119
135
  const absDeltaX = Math.abs(deltaX);
120
136
  const absDeltaY = Math.abs(deltaY);
121
137
  if (isHorizontal) {
@@ -125,11 +141,7 @@ function getOwnedWheelDelta(event, container, isHorizontal, wheelMode) {
125
141
  if (wheelMode === "axis" && absDeltaX > absDeltaY) return null;
126
142
  return deltaY;
127
143
  }
128
- //#endregion
129
- //#region src/ReactSway.tsx
130
- /**
131
- * Core ReactSway component implementing axis-aware infinite scrolling interactions.
132
- */
144
+ // src/ReactSway.tsx
133
145
  /** Velocity applied per arrow key press (pixels). */
134
146
  var ARROW_KEY_VELOCITY = 15;
135
147
  /** Default friction coefficient applied to velocity each frame. */
@@ -160,6 +172,10 @@ var WHEEL_VELOCITY_MULTIPLIER = .14;
160
172
  var WHEEL_IMMEDIATE_DELTA_FACTOR = .14;
161
173
  /** Minimum touch movement before a gesture is classified by axis. */
162
174
  var TOUCH_AXIS_LOCK_THRESHOLD_PX = 8;
175
+ var INERT_ATTRIBUTE = { inert: Number.parseInt(version, 10) >= 19 ? true : "" };
176
+ function isInteractiveTarget(target) {
177
+ return target instanceof Element && Boolean(target.closest("a, button, input, select, textarea, [contenteditable]:not([contenteditable=\"false\"])"));
178
+ }
163
179
  function getVisibleEdgeInterval(rect, isHorizontal) {
164
180
  const rectEnd = isHorizontal ? rect.right : rect.bottom;
165
181
  const rectStart = isHorizontal ? rect.left : rect.top;
@@ -179,11 +195,11 @@ function getVisibleEdgeInterval(rect, isHorizontal) {
179
195
  * Renders children in a continuously looping scroll area with support for
180
196
  * auto-scrolling, mouse drag, touch swipe, wheel, keyboard interactions,
181
197
  * and optional edge-hover scrolling.
182
- * Content is duplicated to create a seamless loop effect. Duplicate content
198
+ * Planar content is duplicated to create a seamless loop effect. Duplicate content
183
199
  * is wrapped in `<aside>` elements with `aria-hidden="true"` for accessibility.
184
200
  *
185
- * Respects `prefers-reduced-motion: reduce` by lowering auto-scroll speed
186
- * and disabling momentum effects.
201
+ * Reduced motion stops depth auto-scroll, lowers planar auto-scroll speed,
202
+ * and disables momentum effects.
187
203
  *
188
204
  * @example
189
205
  * ```tsx
@@ -193,8 +209,14 @@ function getVisibleEdgeInterval(rect, isHorizontal) {
193
209
  * </ReactSway>
194
210
  * ```
195
211
  */
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) {
197
- const isHorizontal = useMemo(() => axis ?? (direction === "left" || direction === "right" ? "horizontal" : "vertical"), [axis, direction]) === "horizontal";
212
+ var ReactSway = forwardRef(function ReactSway({ active = true, ariaLabel = "Scrollable content", axis, autoScroll = true, children, className, depthPerspective = 800, depthSpacing = 420, 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, reducedMotion = false, resumeDelay = DEFAULT_RESUME_DELAY, speed = DEFAULT_SPEED, wheelEnabled = true, wheelMode = "axis" }, ref) {
213
+ const scrollAxis = useMemo(() => axis ?? (direction === "toward" || direction === "away" ? "depth" : direction === "left" || direction === "right" ? "horizontal" : "vertical"), [axis, direction]);
214
+ const isHorizontal = scrollAxis === "horizontal";
215
+ const isDepth = scrollAxis === "depth";
216
+ const depthChildren = useMemo(() => Children.toArray(children), [children]);
217
+ const enabled = active && (!isDepth || depthChildren.length > 0);
218
+ const normalizedDepthPerspective = normalizeDepthLength(depthPerspective, 800);
219
+ const normalizedDepthSpacing = normalizeDepthLength(depthSpacing, 420);
198
220
  const normalizedEdgeHoverSize = useMemo(() => Number.isFinite(edgeHoverSize) ? Math.max(1, edgeHoverSize) : 96, [edgeHoverSize]);
199
221
  const normalizedEdgeHoverSpeedMultiplier = useMemo(() => normalizeEdgeHoverSpeedMultiplier(edgeHoverSpeedMultiplier), [edgeHoverSpeedMultiplier]);
200
222
  const normalizedFriction = useMemo(() => Number.isFinite(friction) ? Math.min(Math.max(friction, 0), 1) : DEFAULT_FRICTION, [friction]);
@@ -202,17 +224,22 @@ var ReactSway = forwardRef(function ReactSway({ ariaLabel = "Scrollable content"
202
224
  const normalizedResumeDelay = useMemo(() => Number.isFinite(resumeDelay) ? Math.max(0, resumeDelay) : DEFAULT_RESUME_DELAY, [resumeDelay]);
203
225
  const normalizedSpeed = useMemo(() => Number.isFinite(speed) ? Math.max(0, speed) : DEFAULT_SPEED, [speed]);
204
226
  const touchAction = useMemo(() => {
205
- if (!draggable) return "auto";
227
+ if (!enabled || !draggable) return "auto";
206
228
  return isHorizontal ? "pan-y" : "pan-x";
207
- }, [draggable, isHorizontal]);
208
- const overscrollBehavior = wheelEnabled && wheelMode === "capture" ? "contain" : "auto";
229
+ }, [
230
+ draggable,
231
+ enabled,
232
+ isHorizontal
233
+ ]);
234
+ const overscrollBehavior = enabled && wheelEnabled && wheelMode === "capture" ? "contain" : "auto";
209
235
  const [isPaused, setIsPaused] = useState(false);
210
236
  const [isTabActive, setIsTabActive] = useState(() => typeof document === "undefined" || !document.hidden);
211
237
  const [loopSegmentCount, setLoopSegmentCount] = useState(MIN_LOOP_SEGMENTS);
212
- const [prefersReducedMotion, setPrefersReducedMotion] = useState(() => {
238
+ const [systemReducedMotion, setSystemReducedMotion] = useState(() => {
213
239
  if (typeof window === "undefined") return false;
214
240
  return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
215
241
  });
242
+ const prefersReducedMotion = systemReducedMotion || reducedMotion;
216
243
  const animationFrameRef = useRef(null);
217
244
  const animationLoopRef = useRef(null);
218
245
  const autoScrollRef = useRef({
@@ -259,7 +286,7 @@ var ReactSway = forwardRef(function ReactSway({ ariaLabel = "Scrollable content"
259
286
  if (typeof window === "undefined") return;
260
287
  const mediaQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
261
288
  const handleChange = (e) => {
262
- setPrefersReducedMotion(e.matches);
289
+ setSystemReducedMotion(e.matches);
263
290
  };
264
291
  mediaQuery.addEventListener("change", handleChange);
265
292
  return () => {
@@ -299,11 +326,28 @@ var ReactSway = forwardRef(function ReactSway({ ariaLabel = "Scrollable content"
299
326
  }, [isHorizontal]);
300
327
  const renderPosition = useCallback((rawPosition) => {
301
328
  if (!containerRef.current) return;
329
+ if (isDepth) {
330
+ containerRef.current.style.transform = "translate3d(0, 0, 0)";
331
+ const items = originalGroupRef.current?.children;
332
+ if (!items) return;
333
+ Array.from(items).forEach((item, index) => {
334
+ const { opacity, z } = getDepthItemState(rawPosition, index, items.length, normalizedDepthPerspective, normalizedDepthSpacing);
335
+ const element = item;
336
+ element.style.transform = `translate3d(0, 0, ${z}px)`;
337
+ element.style.opacity = String(opacity);
338
+ });
339
+ return;
340
+ }
302
341
  const currentLoopPoint = loopPointRef.current;
303
342
  let visualPosition = rawPosition % (currentLoopPoint || 1);
304
343
  if (visualPosition > 0 && currentLoopPoint > 0) visualPosition -= currentLoopPoint;
305
344
  containerRef.current.style.transform = isHorizontal ? `translate3d(${visualPosition}px, 0, 0)` : `translate3d(0, ${visualPosition}px, 0)`;
306
- }, [isHorizontal]);
345
+ }, [
346
+ isDepth,
347
+ isHorizontal,
348
+ normalizedDepthPerspective,
349
+ normalizedDepthSpacing
350
+ ]);
307
351
  const commitPosition = useCallback((nextPosition) => {
308
352
  if (positionRef.current === nextPosition) return;
309
353
  positionRef.current = nextPosition;
@@ -324,11 +368,14 @@ var ReactSway = forwardRef(function ReactSway({ ariaLabel = "Scrollable content"
324
368
  }, [draggable]);
325
369
  const recalculateLoopPoint = useCallback(() => {
326
370
  if (!containerRef.current || !originalGroupRef.current) return;
327
- const originalContentSize = isHorizontal ? originalGroupRef.current.scrollWidth : originalGroupRef.current.scrollHeight;
328
- if (originalContentSize <= 0) return;
371
+ const originalContentSize = isDepth ? depthChildren.length * normalizedDepthSpacing : isHorizontal ? originalGroupRef.current.scrollWidth : originalGroupRef.current.scrollHeight;
372
+ if (originalContentSize <= 0) {
373
+ if (isDepth) loopPointRef.current = 0;
374
+ return;
375
+ }
329
376
  const viewport = containerRef.current.parentElement ?? containerRef.current;
330
377
  const viewportSize = isHorizontal ? Math.max(viewport.clientWidth, 1) : Math.max(viewport.clientHeight, 1);
331
- const nextLoopSegmentCount = Math.max(MIN_LOOP_SEGMENTS, Math.ceil(viewportSize / originalContentSize) + 1);
378
+ const nextLoopSegmentCount = isDepth ? 1 : Math.max(MIN_LOOP_SEGMENTS, Math.ceil(viewportSize / originalContentSize) + 1);
332
379
  const nextLoopPoint = originalContentSize;
333
380
  const previousMeasurement = lastLoopMeasurementRef.current;
334
381
  const nextMeasurement = {
@@ -341,7 +388,13 @@ var ReactSway = forwardRef(function ReactSway({ ariaLabel = "Scrollable content"
341
388
  setLoopSegmentCount((previousLoopSegmentCount) => previousLoopSegmentCount === nextLoopSegmentCount ? previousLoopSegmentCount : nextLoopSegmentCount);
342
389
  loopPointRef.current = nextLoopPoint;
343
390
  renderPosition(positionRef.current);
344
- }, [isHorizontal, renderPosition]);
391
+ }, [
392
+ depthChildren.length,
393
+ isDepth,
394
+ isHorizontal,
395
+ normalizedDepthSpacing,
396
+ renderPosition
397
+ ]);
345
398
  useEffect(() => {
346
399
  autoScrollRef.current.desired = autoScroll;
347
400
  if (!autoScroll) {
@@ -366,6 +419,7 @@ var ReactSway = forwardRef(function ReactSway({ ariaLabel = "Scrollable content"
366
419
  scheduleLoopPointRecalculation();
367
420
  }, [
368
421
  children,
422
+ isDepth,
369
423
  isHorizontal,
370
424
  scheduleLoopPointRecalculation
371
425
  ]);
@@ -420,7 +474,14 @@ var ReactSway = forwardRef(function ReactSway({ ariaLabel = "Scrollable content"
420
474
  touchDragListenerCleanupRef.current = null;
421
475
  }, []);
422
476
  const handleKeyDown = useCallback((e) => {
423
- if (!keyboard) return;
477
+ if (!enabled || !keyboard || isInteractiveTarget(e.target)) return;
478
+ const arrowDelta = isHorizontal ? e.key === "ArrowLeft" ? ARROW_KEY_VELOCITY : e.key === "ArrowRight" ? -15 : 0 : e.key === "ArrowUp" ? ARROW_KEY_VELOCITY : e.key === "ArrowDown" ? -15 : 0;
479
+ if (prefersReducedMotion && arrowDelta !== 0) {
480
+ e.preventDefault();
481
+ commitPosition(wrapPosition(positionRef.current + arrowDelta));
482
+ velocityRef.current = 0;
483
+ return;
484
+ }
424
485
  switch (e.key) {
425
486
  case " ":
426
487
  e.preventDefault();
@@ -467,18 +528,19 @@ var ReactSway = forwardRef(function ReactSway({ ariaLabel = "Scrollable content"
467
528
  velocityRef.current = 0;
468
529
  pauseAutoScroll();
469
530
  scheduleAutoScrollResume();
470
- break;
471
- default: break;
472
531
  }
473
532
  startAnimationLoop();
474
533
  }, [
475
534
  commitPosition,
535
+ enabled,
476
536
  isHorizontal,
477
537
  keyboard,
478
538
  pauseAutoScroll,
539
+ prefersReducedMotion,
479
540
  scheduleAutoScrollResume,
480
541
  startAnimationLoop,
481
- togglePause
542
+ togglePause,
543
+ wrapPosition
482
544
  ]);
483
545
  const handleMouseMove = useCallback((e) => {
484
546
  if (!isDraggingRef.current) return;
@@ -536,7 +598,7 @@ var ReactSway = forwardRef(function ReactSway({ ariaLabel = "Scrollable content"
536
598
  handleMouseUp
537
599
  ]);
538
600
  const handleMouseDown = useCallback((e) => {
539
- if (!draggable) return;
601
+ if (!enabled || !draggable || e.button !== 0 || isInteractiveTarget(e.target)) return;
540
602
  e.preventDefault();
541
603
  containerRef.current?.focus();
542
604
  setDraggingState(true);
@@ -549,6 +611,7 @@ var ReactSway = forwardRef(function ReactSway({ ariaLabel = "Scrollable content"
549
611
  }, [
550
612
  attachMouseDragListeners,
551
613
  draggable,
614
+ enabled,
552
615
  pauseAutoScroll,
553
616
  setDraggingState,
554
617
  startAnimationLoop
@@ -572,6 +635,7 @@ var ReactSway = forwardRef(function ReactSway({ ariaLabel = "Scrollable content"
572
635
  if (touchInteractionStateRef.current === "idle" || touchInteractionStateRef.current === "ignored") return;
573
636
  if (e.touches.length !== 1) return;
574
637
  const touch = e.touches[0];
638
+ if (!touch) return;
575
639
  if (touchInteractionStateRef.current === "pending") {
576
640
  const totalDeltaX = touch.clientX - touchStartXRef.current;
577
641
  const totalDeltaY = touch.clientY - touchStartYRef.current;
@@ -616,18 +680,24 @@ var ReactSway = forwardRef(function ReactSway({ ariaLabel = "Scrollable content"
616
680
  handleTouchMove
617
681
  ]);
618
682
  const handleTouchStart = useCallback((e) => {
619
- if (!draggable || e.touches.length !== 1) return;
683
+ if (!enabled || !draggable || e.touches.length !== 1 || isInteractiveTarget(e.target)) return;
684
+ const touch = e.touches[0];
685
+ if (!touch) return;
620
686
  containerRef.current?.focus();
621
687
  touchInteractionStateRef.current = "pending";
622
- lastTouchXRef.current = e.touches[0].clientX;
623
- lastTouchYRef.current = e.touches[0].clientY;
624
- touchStartXRef.current = e.touches[0].clientX;
625
- touchStartYRef.current = e.touches[0].clientY;
688
+ lastTouchXRef.current = touch.clientX;
689
+ lastTouchYRef.current = touch.clientY;
690
+ touchStartXRef.current = touch.clientX;
691
+ touchStartYRef.current = touch.clientY;
626
692
  velocityRef.current = 0;
627
693
  attachTouchDragListeners();
628
- }, [attachTouchDragListeners, draggable]);
694
+ }, [
695
+ attachTouchDragListeners,
696
+ draggable,
697
+ enabled
698
+ ]);
629
699
  const handleWheel = useCallback((e) => {
630
- if (!wheelEnabled) return false;
700
+ if (!enabled || !wheelEnabled || e.defaultPrevented) return false;
631
701
  const currentContainer = e.currentTarget instanceof HTMLElement ? e.currentTarget : containerRef.current;
632
702
  if (!currentContainer) return false;
633
703
  const ownedDelta = getOwnedWheelDelta(e, currentContainer, isHorizontal, wheelMode);
@@ -644,6 +714,7 @@ var ReactSway = forwardRef(function ReactSway({ ariaLabel = "Scrollable content"
644
714
  return true;
645
715
  }, [
646
716
  commitPosition,
717
+ enabled,
647
718
  isHorizontal,
648
719
  pauseAutoScroll,
649
720
  scheduleAutoScrollResume,
@@ -653,7 +724,7 @@ var ReactSway = forwardRef(function ReactSway({ ariaLabel = "Scrollable content"
653
724
  wrapPosition
654
725
  ]);
655
726
  const applyEdgeHoverPoint = useCallback((point) => {
656
- if (!edgeHoverScroll) return false;
727
+ if (!enabled || !edgeHoverScroll) return false;
657
728
  const viewport = containerRef.current?.parentElement ?? containerRef.current;
658
729
  if (!viewport) return false;
659
730
  const visibleInterval = readVisibleEdgeInterval(viewport);
@@ -666,6 +737,7 @@ var ReactSway = forwardRef(function ReactSway({ ariaLabel = "Scrollable content"
666
737
  return true;
667
738
  }, [
668
739
  edgeHoverScroll,
740
+ enabled,
669
741
  isHorizontal,
670
742
  normalizedEdgeHoverSize,
671
743
  readVisibleEdgeInterval,
@@ -680,6 +752,9 @@ var ReactSway = forwardRef(function ReactSway({ ariaLabel = "Scrollable content"
680
752
  externalEdgeHoverLeaseRef.current = false;
681
753
  setEdgeHoverIntensity(0);
682
754
  }, [setEdgeHoverIntensity]);
755
+ useEffect(() => {
756
+ if (!edgeHoverScroll) clearEdgeHover();
757
+ }, [clearEdgeHover, edgeHoverScroll]);
683
758
  useImperativeHandle(ref, () => ({
684
759
  clearEdgeHover,
685
760
  handleEdgeHover,
@@ -699,7 +774,7 @@ var ReactSway = forwardRef(function ReactSway({ ariaLabel = "Scrollable content"
699
774
  }, [setEdgeHoverIntensity]);
700
775
  useEffect(() => {
701
776
  const currentContainer = containerRef.current;
702
- if (!currentContainer) return;
777
+ if (!currentContainer || !enabled) return;
703
778
  if (draggable) {
704
779
  currentContainer.addEventListener("mousedown", handleMouseDown);
705
780
  currentContainer.addEventListener("touchstart", handleTouchStart, { passive: true });
@@ -737,6 +812,7 @@ var ReactSway = forwardRef(function ReactSway({ ariaLabel = "Scrollable content"
737
812
  detachMouseDragListeners,
738
813
  detachTouchDragListeners,
739
814
  draggable,
815
+ enabled,
740
816
  edgeHoverScroll,
741
817
  edgeHoverInputMode,
742
818
  clearEdgeHover,
@@ -747,6 +823,24 @@ var ReactSway = forwardRef(function ReactSway({ ariaLabel = "Scrollable content"
747
823
  handleWheel,
748
824
  wheelEnabled
749
825
  ]);
826
+ useEffect(() => {
827
+ if (enabled) return;
828
+ clearInactivityTimer();
829
+ clearEdgeHover();
830
+ detachMouseDragListeners();
831
+ detachTouchDragListeners();
832
+ touchInteractionStateRef.current = "idle";
833
+ setDraggingState(false);
834
+ velocityRef.current = 0;
835
+ autoScrollRef.current.active = autoScrollRef.current.desired;
836
+ }, [
837
+ clearEdgeHover,
838
+ clearInactivityTimer,
839
+ detachMouseDragListeners,
840
+ detachTouchDragListeners,
841
+ enabled,
842
+ setDraggingState
843
+ ]);
750
844
  useEffect(() => {
751
845
  window.addEventListener("resize", scheduleLoopPointRecalculation);
752
846
  return () => {
@@ -795,7 +889,7 @@ var ReactSway = forwardRef(function ReactSway({ ariaLabel = "Scrollable content"
795
889
  };
796
890
  }, [clearInactivityTimer]);
797
891
  useEffect(() => {
798
- if (!isTabActive || isPaused) {
892
+ if (!enabled || !isTabActive || isPaused) {
799
893
  if (animationFrameRef.current !== null) {
800
894
  cancelAnimationFrame(animationFrameRef.current);
801
895
  animationFrameRef.current = null;
@@ -804,7 +898,7 @@ var ReactSway = forwardRef(function ReactSway({ ariaLabel = "Scrollable content"
804
898
  return;
805
899
  }
806
900
  lastFrameTimeRef.current = 0;
807
- const directionMultiplier = direction === "down" || direction === "right" ? 1 : -1;
901
+ const directionMultiplier = direction === "away" || direction === "down" || direction === "right" ? 1 : -1;
808
902
  const animate = (currentTime) => {
809
903
  let deltaTime = lastFrameTimeRef.current ? (currentTime - lastFrameTimeRef.current) / MS_PER_FRAME_60FPS : 1;
810
904
  deltaTime = Math.min(deltaTime, MAX_DELTA_TIME);
@@ -815,7 +909,7 @@ var ReactSway = forwardRef(function ReactSway({ ariaLabel = "Scrollable content"
815
909
  let nextPosition = positionRef.current;
816
910
  const effectiveSpeed = prefersReducedMotion ? normalizedSpeed * REDUCED_MOTION_SPEED_FACTOR : normalizedSpeed;
817
911
  const edgeHoverMultiplier = edgeHoverScroll ? getEdgeHoverVelocityScale(edgeHoverIntensityRef.current, normalizedEdgeHoverSpeedMultiplier) : directionMultiplier;
818
- const shouldAutoScroll = (autoScrollRef.current.desired && autoScrollRef.current.active || externalEdgeHoverLeaseRef.current) && !isDraggingRef.current && (!edgeHoverScroll || edgeHoverMultiplier !== 0);
912
+ const shouldAutoScroll = (autoScrollRef.current.desired && autoScrollRef.current.active || externalEdgeHoverLeaseRef.current) && !isDraggingRef.current && !(isDepth && prefersReducedMotion) && (!edgeHoverScroll || edgeHoverMultiplier !== 0);
819
913
  if (shouldAutoScroll) {
820
914
  const autoScrollDeltaTime = edgeHoverScroll ? Math.min(deltaTime, MAX_EDGE_HOVER_DELTA_TIME) : deltaTime;
821
915
  nextPosition += edgeHoverMultiplier * effectiveSpeed * autoScrollDeltaTime;
@@ -844,6 +938,8 @@ var ReactSway = forwardRef(function ReactSway({ ariaLabel = "Scrollable content"
844
938
  commitPosition,
845
939
  direction,
846
940
  edgeHoverScroll,
941
+ enabled,
942
+ isDepth,
847
943
  isPaused,
848
944
  isTabActive,
849
945
  normalizedFriction,
@@ -876,54 +972,73 @@ var ReactSway = forwardRef(function ReactSway({ ariaLabel = "Scrollable content"
876
972
  lazyRootMargin,
877
973
  normalizedLazyThreshold
878
974
  ]);
879
- return /* @__PURE__ */ jsxs("section", {
975
+ return /* @__PURE__ */ React.createElement("section", {
880
976
  "aria-label": ariaLabel,
881
977
  className: `react-sway-container scroller-content${className ? ` ${className}` : ""}`,
882
978
  ref: containerRef,
883
979
  style: {
884
- cursor: draggable ? isDraggingRef.current ? "grabbing" : "grab" : "default",
980
+ cursor: enabled && draggable ? isDraggingRef.current ? "grabbing" : "grab" : "default",
885
981
  display: isHorizontal ? "flex" : void 0,
886
982
  flexDirection: isHorizontal ? "row" : void 0,
887
- height: isHorizontal ? "100%" : "max-content",
983
+ height: isHorizontal || isDepth ? "100%" : "max-content",
888
984
  MozUserSelect: "none",
889
985
  msUserSelect: "none",
890
986
  minHeight: isHorizontal ? void 0 : "100%",
891
- overflow: "hidden",
987
+ overflow: isDepth ? "visible" : "hidden",
988
+ perspective: isDepth ? normalizedDepthPerspective : void 0,
892
989
  overscrollBehavior,
893
990
  pointerEvents: "auto",
894
991
  position: "absolute",
895
992
  touchAction,
896
993
  transform: "translate3d(0, 0, 0)",
994
+ transformStyle: isDepth ? "preserve-3d" : void 0,
897
995
  userSelect: "none",
898
996
  WebkitUserSelect: "none",
899
997
  width: isHorizontal ? "max-content" : "100%",
900
- willChange: "transform",
998
+ willChange: isDepth ? void 0 : "transform",
901
999
  zIndex: 1
902
1000
  },
903
1001
  onKeyDown: keyboard ? handleKeyDown : void 0,
904
- tabIndex: keyboard ? 0 : void 0,
905
- children: [/* @__PURE__ */ jsx("div", {
906
- className: "content-group original",
907
- ref: originalGroupRef,
908
- style: isHorizontal ? {
909
- display: "flex",
910
- flex: "0 0 auto"
911
- } : void 0,
912
- children
913
- }), Array.from({ length: loopSegmentCount - 1 }, (_, duplicateIndex) => /* @__PURE__ */ jsx("aside", {
914
- "aria-hidden": "true",
915
- className: "content-group duplicate",
916
- "data-duplicate": "true",
917
- inert: true,
918
- role: "presentation",
919
- style: isHorizontal ? {
920
- display: "flex",
921
- flex: "0 0 auto",
922
- pointerEvents: "none"
923
- } : { pointerEvents: "none" },
924
- children
925
- }, `duplicate-${duplicateIndex}`))]
926
- });
1002
+ tabIndex: enabled && keyboard ? 0 : void 0
1003
+ }, /* @__PURE__ */ React.createElement("div", {
1004
+ className: "content-group original",
1005
+ ref: originalGroupRef,
1006
+ style: isDepth ? {
1007
+ height: "100%",
1008
+ position: "relative",
1009
+ transformStyle: "preserve-3d"
1010
+ } : isHorizontal ? {
1011
+ display: "flex",
1012
+ flex: "0 0 auto"
1013
+ } : void 0
1014
+ }, isDepth ? depthChildren.map((child, index) => {
1015
+ const { opacity, z } = getDepthItemState(0, index, depthChildren.length, normalizedDepthPerspective, normalizedDepthSpacing);
1016
+ return /* @__PURE__ */ React.createElement("div", {
1017
+ className: "react-sway-depth-item",
1018
+ key: typeof child === "object" && "key" in child ? child.key : index,
1019
+ style: {
1020
+ height: 0,
1021
+ left: "50%",
1022
+ opacity,
1023
+ position: "absolute",
1024
+ top: "50%",
1025
+ transform: `translate3d(0, 0, ${z}px)`,
1026
+ width: 0,
1027
+ willChange: "transform, opacity"
1028
+ }
1029
+ }, child);
1030
+ }) : children), Array.from({ length: isDepth ? 0 : loopSegmentCount - 1 }, (_, duplicateIndex) => /* @__PURE__ */ React.createElement("aside", {
1031
+ "aria-hidden": "true",
1032
+ className: "content-group duplicate",
1033
+ "data-duplicate": "true",
1034
+ ...INERT_ATTRIBUTE,
1035
+ key: `duplicate-${duplicateIndex}`,
1036
+ role: "presentation",
1037
+ style: isHorizontal ? {
1038
+ display: "flex",
1039
+ flex: "0 0 auto",
1040
+ pointerEvents: "none"
1041
+ } : { pointerEvents: "none" }
1042
+ }, children)));
927
1043
  });
928
- //#endregion
929
- export { DEFAULT_EDGE_HOVER_SIZE, DEFAULT_EDGE_HOVER_SPEED_MULTIPLIER, ReactSway };
1044
+ export { DEFAULT_DEPTH_PERSPECTIVE, DEFAULT_DEPTH_SPACING, DEFAULT_EDGE_HOVER_SIZE, DEFAULT_EDGE_HOVER_SPEED_MULTIPLIER, ReactSway };
package/package.json CHANGED
@@ -6,7 +6,7 @@
6
6
  "bugs": {
7
7
  "url": "https://github.com/lafittemehdy/sway/issues"
8
8
  },
9
- "description": "A React component for smooth infinite scrolling, designed for creating engaging, continuous content streams with minimal configuration.",
9
+ "description": "Infinite horizontal, vertical, and perspective depth scrolling for React, with automatic motion and accessible manual interactions.",
10
10
  "exports": {
11
11
  ".": {
12
12
  "types": "./dist/index.d.ts",
@@ -16,18 +16,18 @@
16
16
  }
17
17
  },
18
18
  "devDependencies": {
19
- "@testing-library/jest-dom": "^6.9.1",
20
- "@testing-library/react": "^16.3.2",
21
- "@types/react": "^19.2.17",
22
- "@types/react-dom": "^19.2.3",
23
- "eslint": "^10.8.0",
19
+ "@testing-library/jest-dom": "^7.0.1",
20
+ "@testing-library/react": "^16.3.3",
21
+ "@types/react": "^19.3.0",
22
+ "@types/react-dom": "^19.3.0",
23
+ "eslint": "^10.14.0",
24
24
  "eslint-plugin-react-hooks": "^7.1.1",
25
- "jsdom": "^29.1.1",
26
- "react": "^19.2.8",
27
- "react-dom": "^19.2.8",
28
- "typescript": "^6.0.3",
29
- "vite": "^8.1.5",
30
- "vitest": "^4.1.10"
25
+ "jsdom": "^31.0.0",
26
+ "react": "^19.3.1",
27
+ "react-dom": "^19.3.1",
28
+ "typescript": "~6.0.3",
29
+ "vite": "^8.3.6",
30
+ "vitest": "^5.1.0"
31
31
  },
32
32
  "files": [
33
33
  "CHANGELOG.md",
@@ -37,9 +37,11 @@
37
37
  "keywords": [
38
38
  "content-stream",
39
39
  "continuous-scroll",
40
+ "depth-scroll",
40
41
  "frontend",
41
42
  "infinite-scroll",
42
43
  "javascript",
44
+ "perspective",
43
45
  "react",
44
46
  "react-component",
45
47
  "scroller",
@@ -74,5 +76,5 @@
74
76
  },
75
77
  "type": "module",
76
78
  "types": "./dist/index.d.ts",
77
- "version": "0.4.2"
79
+ "version": "0.5.0"
78
80
  }