react-sway 0.4.3 → 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/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.0 - 2026-11-09
4
+
5
+ - Add perspective depth scrolling with toward/away directions and configurable camera distance and plane spacing.
6
+ - Mount each depth child once and recycle only at zero opacity without crossing the camera.
7
+ - Share wheel, drag, touch, keyboard, pause, and lifecycle behavior across all three axes.
8
+ - Add `active` and `reducedMotion` for host lifecycle and accessibility integration; preserve React 17/18/19 and existing planar behavior.
9
+ - Add responsive depth documentation and browser coverage, plus geometry and lifecycle regressions.
10
+ - Make the ESM build directly importable in Node with React 17, and verify packed ESM/CJS/SSR consumers on React 17/18/19.
11
+ - Update development dependencies and npm, test Node 22/24/26, and resolve all known dependency audit findings. Keep TypeScript 6.0.3 for ESLint parser compatibility.
12
+
3
13
  ## 0.4.3 - 2026-10-21
4
14
 
5
15
  - Preserve browser zoom gestures, nested controls, and secondary mouse clicks.
package/README.md CHANGED
@@ -1,16 +1,16 @@
1
1
  # react-sway
2
2
 
3
- A React component for smooth, infinite, and interactive content scrolling on the vertical or horizontal axis. It duplicates content to create a seamless looping effect, controllable via touch, mouse drag, wheel, keyboard, or edge hover.
3
+ A React component for smooth, infinite, and interactive content scrolling on vertical, horizontal, or perspective depth axes, controllable via touch, mouse drag, wheel, keyboard, or edge hover. React 17, 18, and 19 are supported without runtime dependencies beyond React and React DOM.
4
4
 
5
5
  ## What is `react-sway`?
6
6
 
7
7
  React Sway takes your list of items and makes them scroll endlessly. It's designed to be easy to use and performant, with auto-scrolling that pauses when users interact. Edge-hover layouts can also be configured to scroll only while the pointer hovers the active axis boundary: top/bottom for vertical loops or left/right for horizontal loops. Edge-hover speed ramps from idle to a bounded multiple of the configured speed as the pointer moves deeper into the boundary zone.
8
8
 
9
- It works by duplicating your content to create a seamless loop and uses CSS transforms for smooth animation. The duplicated content is wrapped in inert `<aside>` elements with `aria-hidden="true"` and `role="presentation"` so assistive technology, pointer input, and search crawlers keep treating the original group as canonical.
9
+ Vertical and horizontal modes duplicate content into inert `<aside>` elements with `aria-hidden="true"` and `role="presentation"`. Depth mode mounts each direct child once and recycles its 3D plane at zero opacity, keeping every plane behind the perspective camera.
10
10
 
11
11
  ### Core Features
12
12
 
13
- * **Smooth Infinite Scroll:** Content loops continuously on the vertical or horizontal axis.
13
+ * **Smooth Infinite Scroll:** Content loops continuously on vertical, horizontal, or depth axes.
14
14
  * **Auto-Scroll:** Scrolls automatically, with configurable speed and direction.
15
15
  * **Edge Hover:** Use `edgeHoverScroll` to keep auto-scroll idle until the pointer enters the active axis boundary zone, with speed scaled smoothly by boundary depth and capped by `edgeHoverSpeedMultiplier`.
16
16
  * **User Friendly Interactions:**
@@ -277,16 +277,51 @@ function MinimalEdgeHoverExamples() {
277
277
  }
278
278
  ```
279
279
 
280
+ ### Perspective depth scrolling
281
+
282
+ ```tsx
283
+ import { ReactSway } from 'react-sway';
284
+
285
+ function DepthGallery({ active = true }: { active?: boolean }) {
286
+ return (
287
+ <div className="depth-viewport">
288
+ <ReactSway active={active} ariaLabel="Photo gallery" axis="depth" direction="toward">
289
+ <img alt="Mountain landscape" className="depth-image" src="/mountain.jpg" />
290
+ <img alt="Modern architecture" className="depth-image depth-image-offset" src="/building.jpg" />
291
+ <img alt="Coastal landscape" className="depth-image" src="/coast.jpg" />
292
+ </ReactSway>
293
+ </div>
294
+ );
295
+ }
296
+ ```
297
+
298
+ ```css
299
+ .depth-viewport { height: 600px; overflow: hidden; position: relative; }
300
+ .depth-image { display: block; height: 200px; max-width: none; object-fit: cover; transform: translate(-50%, -50%); width: 160px; }
301
+ .depth-image-offset { margin-left: 50px; margin-top: -80px; width: 200px; }
302
+ ```
303
+
304
+ Each **direct child** is one plane anchored at the viewport center. A fragment or a list wrapper is one plane; pass separate children for independent motion. Give children explicit sizes and use their CSS for composition, offsets, and tilt. The positioned parent supplies viewport dimensions and clipping. Keep `overflow: hidden` on that parent so the internal 3D scene retains depth.
305
+
306
+ `direction="toward"` approaches the camera; `"away"` recedes. Either direction infers `axis="depth"` when the axis is omitted. `depthPerspective` defaults to 800 CSS pixels and `depthSpacing` to 420; both must be finite and positive. Invalid values use their defaults, positive values below 1 are clamped to 1, and larger values are bounded by `Number.MAX_SAFE_INTEGER`. The exported `DEFAULT_DEPTH_PERSPECTIVE` and `DEFAULT_DEPTH_SPACING` constants share those defaults.
307
+
308
+ The loop period is the direct-child count times `depthSpacing`. Automatic movement and manual wheel/drag/keyboard input use the same periodic phase; vertical gestures and up/down arrows control depth. Set `autoScroll={false}` for manual exploration, or `active={false}` to suspend Sway's animation and input while preserving the current position. Empty depth scenes stay idle. Host applications can request reduced motion with `reducedMotion`; the system preference always takes precedence. Depth auto-scroll and momentum stop under reduced motion, while deliberate manual steps remain available. Existing planar loops retain their reduced speed behavior.
309
+
310
+ For decorative backgrounds, disable `keyboard`, `draggable`, and `wheelEnabled`, use empty image alt text, and put stationary application controls outside the Sway region. Automatic motion should have an accessible pause control. Inspect the [live depth example](https://lafittemehdy.github.io/sway/) for a responsive composition.
311
+
280
312
  ## Props
281
313
 
282
314
  | Prop | Type | Default | Description |
283
315
  |------|------|---------|-------------|
316
+ | `active` | `boolean` | `true` | Suspend Sway animation and input when false, retaining content and position. Nested native controls remain usable. |
284
317
  | `ariaLabel` | `string` | `'Scrollable content'` | Accessible name for the scrolling region. |
285
- | `axis` | `'horizontal' \| 'vertical'` | Inferred from `direction`, otherwise `'vertical'` | Scroll axis. Use `'horizontal'` for left/right loops. |
318
+ | `axis` | `'depth' \| 'horizontal' \| 'vertical'` | Inferred from `direction`, otherwise `'vertical'` | Scroll axis; depth assigns each direct child a 3D plane. |
286
319
  | `autoScroll` | `boolean` | `true` | Enable/disable auto-scrolling. |
287
320
  | `children` | `ReactNode` | - | Content elements to render in the scroll container. |
288
321
  | `className` | `string` | - | Additional class name applied to the semantic scrolling region. |
289
- | `direction` | `'down' \| 'left' \| 'right' \| 'up'` | `'up'` | Auto-scroll direction. `left` and `right` imply horizontal scrolling when `axis` is omitted. |
322
+ | `depthPerspective` | `number` | `800` | Perspective camera distance in CSS pixels, depth mode only. |
323
+ | `depthSpacing` | `number` | `420` | Distance between consecutive depth planes in CSS pixels. |
324
+ | `direction` | `'away' \| 'down' \| 'left' \| 'right' \| 'toward' \| 'up'` | `'up'` | `left`/`right` infer horizontal; `toward`/`away` infer depth. Explicit `axis` takes precedence. |
290
325
  | `draggable` | `boolean` | `true` | Enable mouse/touch drag interaction. |
291
326
  | `edgeHoverInputMode` | `'external' \| 'hybrid'` | `'hybrid'` | Use intrinsic pointer sensing plus the imperative bridge, or external imperative ownership only for composed drag surfaces. |
292
327
  | `edgeHoverScroll` | `boolean` | `false` | Only auto-scroll while hovering the active axis boundary: top/bottom for vertical, left/right for horizontal. A short ingress feather removes sensor chatter; velocity then accelerates through the physical edge and reaches its bounded cap one activation-zone width beyond it. |
@@ -301,6 +336,7 @@ function MinimalEdgeHoverExamples() {
301
336
  | `onResume` | `() => void` | - | Fired when interaction-paused autonomous scrolling resumes. |
302
337
  | `onScroll` | `(position: number) => void` | - | Fired on every position change with the current scroll position. |
303
338
  | `pauseOnInteraction` | `boolean` | `true` | Pause auto-scroll during user interaction. |
339
+ | `reducedMotion` | `boolean` | `false` | Request reduced motion in addition to the OS preference. Stops depth auto-scroll and momentum. |
304
340
  | `resumeDelay` | `number` | `2000` | Milliseconds before auto-scroll resumes after interaction. |
305
341
  | `speed` | `number` | `0.5` | Baseline auto-scroll speed in pixels per frame at 60fps. Edge-hover mode preserves this baseline inside the active zone and adds bounded depth acceleration. |
306
342
  | `wheelEnabled` | `boolean` | `true` | Enable mouse wheel scrolling. |
@@ -374,11 +410,12 @@ External drag owners should retain pointer capture or route global pointer coord
374
410
 
375
411
  ## Runtime and accessibility contract
376
412
 
377
- - Duplicate loop groups are inert, `aria-hidden`, presentation-only, non-interactive, and pointer-disabled.
413
+ - Planar duplicate loop groups are inert, `aria-hidden`, presentation-only, non-interactive, and pointer-disabled. Depth children are never duplicated.
414
+ - Hidden pages pause animation and resume without consuming elapsed hidden time. Inactive instances also relinquish gesture ownership and discard momentum.
378
415
  - Mouse and touch global listeners are installed only during active drag gestures and are removed on release, cancel, blur, unmount, or prop changes.
379
416
  - Edge-hover mode does not continuously request animation frames while idle; it wakes when the pointer enters an edge, wheel input arrives, momentum exists, or auto-scroll resumes.
380
417
  - `wheelMode="axis"` lets cross-axis page scroll pass through. `wheelMode="capture"` intentionally lets Sway own every wheel event it receives.
381
418
 
382
419
  ## License
383
420
 
384
- This package is licensed under the [MIT License](../../LICENSE).
421
+ This package is licensed under the [MIT License](https://github.com/lafittemehdy/sway/blob/main/LICENSE).
@@ -4,8 +4,8 @@
4
4
  import { type ReactNode } from 'react';
5
5
  import { type SwayWheelMode } from './wheel-input';
6
6
  export type { SwayWheelMode } from './wheel-input';
7
- export type SwayAxis = 'horizontal' | 'vertical';
8
- export type SwayDirection = 'down' | 'left' | 'right' | 'up';
7
+ export type SwayAxis = 'depth' | 'horizontal' | 'vertical';
8
+ export type SwayDirection = 'away' | 'down' | 'left' | 'right' | 'toward' | 'up';
9
9
  export type SwayEdgeHoverInputMode = 'external' | 'hybrid';
10
10
  export interface ReactSwayHandle {
11
11
  /**
@@ -36,9 +36,11 @@ export interface ReactSwayHandle {
36
36
  * Props for the ReactSway infinite scrolling component.
37
37
  */
38
38
  export interface ReactSwayProps {
39
+ /** Suspend Sway animation and input while keeping content and scroll position mounted. @default true */
40
+ active?: boolean;
39
41
  /** Accessible name for the scrolling region. */
40
42
  ariaLabel?: string;
41
- /** Scroll axis. Horizontal directions imply `horizontal` when omitted. @default 'vertical' */
43
+ /** Scroll axis. Left/right imply horizontal; toward/away imply depth. @default 'vertical' */
42
44
  axis?: SwayAxis;
43
45
  /** Enable/disable auto-scrolling. @default true */
44
46
  autoScroll?: boolean;
@@ -46,6 +48,10 @@ export interface ReactSwayProps {
46
48
  children: ReactNode;
47
49
  /** Additional class name applied to the semantic scrolling region. */
48
50
  className?: string;
51
+ /** Camera distance in CSS pixels for depth mode. Invalid lengths use the default. @default 800 */
52
+ depthPerspective?: number;
53
+ /** Distance in CSS pixels between depth planes. Invalid lengths use the default. @default 420 */
54
+ depthSpacing?: number;
49
55
  /** Auto-scroll direction. @default 'up' */
50
56
  direction?: SwayDirection;
51
57
  /** Enable mouse/touch drag interaction. @default true */
@@ -83,6 +89,8 @@ export interface ReactSwayProps {
83
89
  onScroll?: (position: number) => void;
84
90
  /** Pause auto-scroll during user interaction. @default true */
85
91
  pauseOnInteraction?: boolean;
92
+ /** Request reduced motion in addition to the system preference; false never overrides the system. @default false */
93
+ reducedMotion?: boolean;
86
94
  /** Milliseconds before auto-scroll resumes after interaction. @default 2000 */
87
95
  resumeDelay?: number;
88
96
  /** Baseline auto-scroll speed in pixels per frame at 60fps. @default 0.5 */
@@ -106,11 +114,11 @@ export interface ReactSwayProps {
106
114
  * Renders children in a continuously looping scroll area with support for
107
115
  * auto-scrolling, mouse drag, touch swipe, wheel, keyboard interactions,
108
116
  * and optional edge-hover scrolling.
109
- * Content is duplicated to create a seamless loop effect. Duplicate content
117
+ * Planar content is duplicated to create a seamless loop effect. Duplicate content
110
118
  * is wrapped in `<aside>` elements with `aria-hidden="true"` for accessibility.
111
119
  *
112
- * Respects `prefers-reduced-motion: reduce` by lowering auto-scroll speed
113
- * and disabling momentum effects.
120
+ * Reduced motion stops depth auto-scroll, lowers planar auto-scroll speed,
121
+ * and disables momentum effects.
114
122
  *
115
123
  * @example
116
124
  * ```tsx
@@ -0,0 +1,9 @@
1
+ /** Maps one scroll phase onto periodic depth planes without crossing the perspective camera. */
2
+ export declare const DEFAULT_DEPTH_PERSPECTIVE = 800;
3
+ export declare const DEFAULT_DEPTH_SPACING = 420;
4
+ /** Bounds pixel lengths so periods and fade denominators remain finite and nonzero. */
5
+ export declare function normalizeDepthLength(value: number, fallback: number): number;
6
+ export declare function getDepthItemState(position: number, index: number, count: number, perspective?: number, spacing?: number): {
7
+ opacity: number;
8
+ z: number;
9
+ };
package/dist/index.cjs CHANGED
@@ -1,10 +1,48 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ // \0rolldown/runtime.js
3
+ var __create = Object.create;
4
+ var __defProp = Object.defineProperty;
5
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
6
+ var __getOwnPropNames = Object.getOwnPropertyNames;
7
+ var __getProtoOf = Object.getPrototypeOf;
8
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
9
+ var __copyProps = (to, from, except, desc) => {
10
+ if (from && typeof from === "object" || typeof from === "function") for (var keys = __getOwnPropNames(from), i = 0, n = keys.length, key; i < n; i++) {
11
+ key = keys[i];
12
+ if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, {
13
+ get: ((k) => from[k]).bind(null, key),
14
+ enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable
15
+ });
16
+ }
17
+ return to;
18
+ };
19
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(isNodeMode || !mod || !mod.__esModule || !__hasOwnProp.call(mod, "default") ? __defProp(target, "default", {
20
+ value: mod,
21
+ enumerable: true
22
+ }) : target, mod));
2
23
  let react = require("react");
3
- let react_jsx_runtime = require("react/jsx-runtime");
4
- //#region src/edge-hover-velocity.ts
5
- /**
6
- * Defines the bounded spatial mapping from edge-hover depth to Sway velocity.
7
- */
24
+ react = __toESM(react, 1);
25
+ // src/depth-position.ts
26
+ var DEFAULT_DEPTH_PERSPECTIVE = 800;
27
+ var DEFAULT_DEPTH_SPACING = 420;
28
+ /** Bounds pixel lengths so periods and fade denominators remain finite and nonzero. */
29
+ function normalizeDepthLength(value, fallback) {
30
+ return Number.isFinite(value) && value > 0 ? Math.min(Number.MAX_SAFE_INTEGER, Math.max(1, value)) : fallback;
31
+ }
32
+ function getDepthItemState(position, index, count, perspective = 800, spacing = 420) {
33
+ if (count <= 0) return {
34
+ opacity: 0,
35
+ z: 0
36
+ };
37
+ const period = count * spacing;
38
+ const phase = ((index * spacing + position) % period + period) % period;
39
+ const fade = Math.min(1, Math.min(phase, period - phase) / (spacing * .45));
40
+ return {
41
+ opacity: fade * fade * (3 - 2 * fade),
42
+ z: perspective * .7 - phase
43
+ };
44
+ }
45
+ // src/edge-hover-velocity.ts
8
46
  /** Default edge-hover activation thickness in pixels. */
9
47
  var DEFAULT_EDGE_HOVER_SIZE = 96;
10
48
  /** Default maximum edge-hover speed relative to the configured base speed. */
@@ -79,9 +117,7 @@ function getEdgeHoverIntensity(pointerPosition, visibleInterval, edgeHoverSize)
79
117
  }
80
118
  return 0;
81
119
  }
82
- //#endregion
83
- //#region src/wheel-input.ts
84
- /** Fallback pixel height for wheel events reported in line units. */
120
+ // src/wheel-input.ts
85
121
  var WHEEL_LINE_HEIGHT_FALLBACK_PX = 16;
86
122
  /** WheelEvent deltaMode value for line-based deltas. */
87
123
  var WHEEL_MODE_LINE = 1;
@@ -128,11 +164,7 @@ function getOwnedWheelDelta(event, container, isHorizontal, wheelMode) {
128
164
  if (wheelMode === "axis" && absDeltaX > absDeltaY) return null;
129
165
  return deltaY;
130
166
  }
131
- //#endregion
132
- //#region src/ReactSway.tsx
133
- /**
134
- * Core ReactSway component implementing axis-aware infinite scrolling interactions.
135
- */
167
+ // src/ReactSway.tsx
136
168
  /** Velocity applied per arrow key press (pixels). */
137
169
  var ARROW_KEY_VELOCITY = 15;
138
170
  /** Default friction coefficient applied to velocity each frame. */
@@ -186,11 +218,11 @@ function getVisibleEdgeInterval(rect, isHorizontal) {
186
218
  * Renders children in a continuously looping scroll area with support for
187
219
  * auto-scrolling, mouse drag, touch swipe, wheel, keyboard interactions,
188
220
  * and optional edge-hover scrolling.
189
- * Content is duplicated to create a seamless loop effect. Duplicate content
221
+ * Planar content is duplicated to create a seamless loop effect. Duplicate content
190
222
  * is wrapped in `<aside>` elements with `aria-hidden="true"` for accessibility.
191
223
  *
192
- * Respects `prefers-reduced-motion: reduce` by lowering auto-scroll speed
193
- * and disabling momentum effects.
224
+ * Reduced motion stops depth auto-scroll, lowers planar auto-scroll speed,
225
+ * and disables momentum effects.
194
226
  *
195
227
  * @example
196
228
  * ```tsx
@@ -200,8 +232,14 @@ function getVisibleEdgeInterval(rect, isHorizontal) {
200
232
  * </ReactSway>
201
233
  * ```
202
234
  */
203
- var ReactSway = (0, react.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) {
204
- const isHorizontal = (0, react.useMemo)(() => axis ?? (direction === "left" || direction === "right" ? "horizontal" : "vertical"), [axis, direction]) === "horizontal";
235
+ var ReactSway = (0, react.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) {
236
+ const scrollAxis = (0, react.useMemo)(() => axis ?? (direction === "toward" || direction === "away" ? "depth" : direction === "left" || direction === "right" ? "horizontal" : "vertical"), [axis, direction]);
237
+ const isHorizontal = scrollAxis === "horizontal";
238
+ const isDepth = scrollAxis === "depth";
239
+ const depthChildren = (0, react.useMemo)(() => react.Children.toArray(children), [children]);
240
+ const enabled = active && (!isDepth || depthChildren.length > 0);
241
+ const normalizedDepthPerspective = normalizeDepthLength(depthPerspective, 800);
242
+ const normalizedDepthSpacing = normalizeDepthLength(depthSpacing, 420);
205
243
  const normalizedEdgeHoverSize = (0, react.useMemo)(() => Number.isFinite(edgeHoverSize) ? Math.max(1, edgeHoverSize) : 96, [edgeHoverSize]);
206
244
  const normalizedEdgeHoverSpeedMultiplier = (0, react.useMemo)(() => normalizeEdgeHoverSpeedMultiplier(edgeHoverSpeedMultiplier), [edgeHoverSpeedMultiplier]);
207
245
  const normalizedFriction = (0, react.useMemo)(() => Number.isFinite(friction) ? Math.min(Math.max(friction, 0), 1) : DEFAULT_FRICTION, [friction]);
@@ -209,17 +247,22 @@ var ReactSway = (0, react.forwardRef)(function ReactSway({ ariaLabel = "Scrollab
209
247
  const normalizedResumeDelay = (0, react.useMemo)(() => Number.isFinite(resumeDelay) ? Math.max(0, resumeDelay) : DEFAULT_RESUME_DELAY, [resumeDelay]);
210
248
  const normalizedSpeed = (0, react.useMemo)(() => Number.isFinite(speed) ? Math.max(0, speed) : DEFAULT_SPEED, [speed]);
211
249
  const touchAction = (0, react.useMemo)(() => {
212
- if (!draggable) return "auto";
250
+ if (!enabled || !draggable) return "auto";
213
251
  return isHorizontal ? "pan-y" : "pan-x";
214
- }, [draggable, isHorizontal]);
215
- const overscrollBehavior = wheelEnabled && wheelMode === "capture" ? "contain" : "auto";
252
+ }, [
253
+ draggable,
254
+ enabled,
255
+ isHorizontal
256
+ ]);
257
+ const overscrollBehavior = enabled && wheelEnabled && wheelMode === "capture" ? "contain" : "auto";
216
258
  const [isPaused, setIsPaused] = (0, react.useState)(false);
217
259
  const [isTabActive, setIsTabActive] = (0, react.useState)(() => typeof document === "undefined" || !document.hidden);
218
260
  const [loopSegmentCount, setLoopSegmentCount] = (0, react.useState)(MIN_LOOP_SEGMENTS);
219
- const [prefersReducedMotion, setPrefersReducedMotion] = (0, react.useState)(() => {
261
+ const [systemReducedMotion, setSystemReducedMotion] = (0, react.useState)(() => {
220
262
  if (typeof window === "undefined") return false;
221
263
  return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
222
264
  });
265
+ const prefersReducedMotion = systemReducedMotion || reducedMotion;
223
266
  const animationFrameRef = (0, react.useRef)(null);
224
267
  const animationLoopRef = (0, react.useRef)(null);
225
268
  const autoScrollRef = (0, react.useRef)({
@@ -266,7 +309,7 @@ var ReactSway = (0, react.forwardRef)(function ReactSway({ ariaLabel = "Scrollab
266
309
  if (typeof window === "undefined") return;
267
310
  const mediaQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
268
311
  const handleChange = (e) => {
269
- setPrefersReducedMotion(e.matches);
312
+ setSystemReducedMotion(e.matches);
270
313
  };
271
314
  mediaQuery.addEventListener("change", handleChange);
272
315
  return () => {
@@ -306,11 +349,28 @@ var ReactSway = (0, react.forwardRef)(function ReactSway({ ariaLabel = "Scrollab
306
349
  }, [isHorizontal]);
307
350
  const renderPosition = (0, react.useCallback)((rawPosition) => {
308
351
  if (!containerRef.current) return;
352
+ if (isDepth) {
353
+ containerRef.current.style.transform = "translate3d(0, 0, 0)";
354
+ const items = originalGroupRef.current?.children;
355
+ if (!items) return;
356
+ Array.from(items).forEach((item, index) => {
357
+ const { opacity, z } = getDepthItemState(rawPosition, index, items.length, normalizedDepthPerspective, normalizedDepthSpacing);
358
+ const element = item;
359
+ element.style.transform = `translate3d(0, 0, ${z}px)`;
360
+ element.style.opacity = String(opacity);
361
+ });
362
+ return;
363
+ }
309
364
  const currentLoopPoint = loopPointRef.current;
310
365
  let visualPosition = rawPosition % (currentLoopPoint || 1);
311
366
  if (visualPosition > 0 && currentLoopPoint > 0) visualPosition -= currentLoopPoint;
312
367
  containerRef.current.style.transform = isHorizontal ? `translate3d(${visualPosition}px, 0, 0)` : `translate3d(0, ${visualPosition}px, 0)`;
313
- }, [isHorizontal]);
368
+ }, [
369
+ isDepth,
370
+ isHorizontal,
371
+ normalizedDepthPerspective,
372
+ normalizedDepthSpacing
373
+ ]);
314
374
  const commitPosition = (0, react.useCallback)((nextPosition) => {
315
375
  if (positionRef.current === nextPosition) return;
316
376
  positionRef.current = nextPosition;
@@ -331,11 +391,14 @@ var ReactSway = (0, react.forwardRef)(function ReactSway({ ariaLabel = "Scrollab
331
391
  }, [draggable]);
332
392
  const recalculateLoopPoint = (0, react.useCallback)(() => {
333
393
  if (!containerRef.current || !originalGroupRef.current) return;
334
- const originalContentSize = isHorizontal ? originalGroupRef.current.scrollWidth : originalGroupRef.current.scrollHeight;
335
- if (originalContentSize <= 0) return;
394
+ const originalContentSize = isDepth ? depthChildren.length * normalizedDepthSpacing : isHorizontal ? originalGroupRef.current.scrollWidth : originalGroupRef.current.scrollHeight;
395
+ if (originalContentSize <= 0) {
396
+ if (isDepth) loopPointRef.current = 0;
397
+ return;
398
+ }
336
399
  const viewport = containerRef.current.parentElement ?? containerRef.current;
337
400
  const viewportSize = isHorizontal ? Math.max(viewport.clientWidth, 1) : Math.max(viewport.clientHeight, 1);
338
- const nextLoopSegmentCount = Math.max(MIN_LOOP_SEGMENTS, Math.ceil(viewportSize / originalContentSize) + 1);
401
+ const nextLoopSegmentCount = isDepth ? 1 : Math.max(MIN_LOOP_SEGMENTS, Math.ceil(viewportSize / originalContentSize) + 1);
339
402
  const nextLoopPoint = originalContentSize;
340
403
  const previousMeasurement = lastLoopMeasurementRef.current;
341
404
  const nextMeasurement = {
@@ -348,7 +411,13 @@ var ReactSway = (0, react.forwardRef)(function ReactSway({ ariaLabel = "Scrollab
348
411
  setLoopSegmentCount((previousLoopSegmentCount) => previousLoopSegmentCount === nextLoopSegmentCount ? previousLoopSegmentCount : nextLoopSegmentCount);
349
412
  loopPointRef.current = nextLoopPoint;
350
413
  renderPosition(positionRef.current);
351
- }, [isHorizontal, renderPosition]);
414
+ }, [
415
+ depthChildren.length,
416
+ isDepth,
417
+ isHorizontal,
418
+ normalizedDepthSpacing,
419
+ renderPosition
420
+ ]);
352
421
  (0, react.useEffect)(() => {
353
422
  autoScrollRef.current.desired = autoScroll;
354
423
  if (!autoScroll) {
@@ -373,6 +442,7 @@ var ReactSway = (0, react.forwardRef)(function ReactSway({ ariaLabel = "Scrollab
373
442
  scheduleLoopPointRecalculation();
374
443
  }, [
375
444
  children,
445
+ isDepth,
376
446
  isHorizontal,
377
447
  scheduleLoopPointRecalculation
378
448
  ]);
@@ -427,7 +497,14 @@ var ReactSway = (0, react.forwardRef)(function ReactSway({ ariaLabel = "Scrollab
427
497
  touchDragListenerCleanupRef.current = null;
428
498
  }, []);
429
499
  const handleKeyDown = (0, react.useCallback)((e) => {
430
- if (!keyboard || isInteractiveTarget(e.target)) return;
500
+ if (!enabled || !keyboard || isInteractiveTarget(e.target)) return;
501
+ 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;
502
+ if (prefersReducedMotion && arrowDelta !== 0) {
503
+ e.preventDefault();
504
+ commitPosition(wrapPosition(positionRef.current + arrowDelta));
505
+ velocityRef.current = 0;
506
+ return;
507
+ }
431
508
  switch (e.key) {
432
509
  case " ":
433
510
  e.preventDefault();
@@ -478,12 +555,15 @@ var ReactSway = (0, react.forwardRef)(function ReactSway({ ariaLabel = "Scrollab
478
555
  startAnimationLoop();
479
556
  }, [
480
557
  commitPosition,
558
+ enabled,
481
559
  isHorizontal,
482
560
  keyboard,
483
561
  pauseAutoScroll,
562
+ prefersReducedMotion,
484
563
  scheduleAutoScrollResume,
485
564
  startAnimationLoop,
486
- togglePause
565
+ togglePause,
566
+ wrapPosition
487
567
  ]);
488
568
  const handleMouseMove = (0, react.useCallback)((e) => {
489
569
  if (!isDraggingRef.current) return;
@@ -541,7 +621,7 @@ var ReactSway = (0, react.forwardRef)(function ReactSway({ ariaLabel = "Scrollab
541
621
  handleMouseUp
542
622
  ]);
543
623
  const handleMouseDown = (0, react.useCallback)((e) => {
544
- if (!draggable || e.button !== 0 || isInteractiveTarget(e.target)) return;
624
+ if (!enabled || !draggable || e.button !== 0 || isInteractiveTarget(e.target)) return;
545
625
  e.preventDefault();
546
626
  containerRef.current?.focus();
547
627
  setDraggingState(true);
@@ -554,6 +634,7 @@ var ReactSway = (0, react.forwardRef)(function ReactSway({ ariaLabel = "Scrollab
554
634
  }, [
555
635
  attachMouseDragListeners,
556
636
  draggable,
637
+ enabled,
557
638
  pauseAutoScroll,
558
639
  setDraggingState,
559
640
  startAnimationLoop
@@ -577,6 +658,7 @@ var ReactSway = (0, react.forwardRef)(function ReactSway({ ariaLabel = "Scrollab
577
658
  if (touchInteractionStateRef.current === "idle" || touchInteractionStateRef.current === "ignored") return;
578
659
  if (e.touches.length !== 1) return;
579
660
  const touch = e.touches[0];
661
+ if (!touch) return;
580
662
  if (touchInteractionStateRef.current === "pending") {
581
663
  const totalDeltaX = touch.clientX - touchStartXRef.current;
582
664
  const totalDeltaY = touch.clientY - touchStartYRef.current;
@@ -621,18 +703,24 @@ var ReactSway = (0, react.forwardRef)(function ReactSway({ ariaLabel = "Scrollab
621
703
  handleTouchMove
622
704
  ]);
623
705
  const handleTouchStart = (0, react.useCallback)((e) => {
624
- if (!draggable || e.touches.length !== 1 || isInteractiveTarget(e.target)) return;
706
+ if (!enabled || !draggable || e.touches.length !== 1 || isInteractiveTarget(e.target)) return;
707
+ const touch = e.touches[0];
708
+ if (!touch) return;
625
709
  containerRef.current?.focus();
626
710
  touchInteractionStateRef.current = "pending";
627
- lastTouchXRef.current = e.touches[0].clientX;
628
- lastTouchYRef.current = e.touches[0].clientY;
629
- touchStartXRef.current = e.touches[0].clientX;
630
- touchStartYRef.current = e.touches[0].clientY;
711
+ lastTouchXRef.current = touch.clientX;
712
+ lastTouchYRef.current = touch.clientY;
713
+ touchStartXRef.current = touch.clientX;
714
+ touchStartYRef.current = touch.clientY;
631
715
  velocityRef.current = 0;
632
716
  attachTouchDragListeners();
633
- }, [attachTouchDragListeners, draggable]);
717
+ }, [
718
+ attachTouchDragListeners,
719
+ draggable,
720
+ enabled
721
+ ]);
634
722
  const handleWheel = (0, react.useCallback)((e) => {
635
- if (!wheelEnabled || e.defaultPrevented) return false;
723
+ if (!enabled || !wheelEnabled || e.defaultPrevented) return false;
636
724
  const currentContainer = e.currentTarget instanceof HTMLElement ? e.currentTarget : containerRef.current;
637
725
  if (!currentContainer) return false;
638
726
  const ownedDelta = getOwnedWheelDelta(e, currentContainer, isHorizontal, wheelMode);
@@ -649,6 +737,7 @@ var ReactSway = (0, react.forwardRef)(function ReactSway({ ariaLabel = "Scrollab
649
737
  return true;
650
738
  }, [
651
739
  commitPosition,
740
+ enabled,
652
741
  isHorizontal,
653
742
  pauseAutoScroll,
654
743
  scheduleAutoScrollResume,
@@ -658,7 +747,7 @@ var ReactSway = (0, react.forwardRef)(function ReactSway({ ariaLabel = "Scrollab
658
747
  wrapPosition
659
748
  ]);
660
749
  const applyEdgeHoverPoint = (0, react.useCallback)((point) => {
661
- if (!edgeHoverScroll) return false;
750
+ if (!enabled || !edgeHoverScroll) return false;
662
751
  const viewport = containerRef.current?.parentElement ?? containerRef.current;
663
752
  if (!viewport) return false;
664
753
  const visibleInterval = readVisibleEdgeInterval(viewport);
@@ -671,6 +760,7 @@ var ReactSway = (0, react.forwardRef)(function ReactSway({ ariaLabel = "Scrollab
671
760
  return true;
672
761
  }, [
673
762
  edgeHoverScroll,
763
+ enabled,
674
764
  isHorizontal,
675
765
  normalizedEdgeHoverSize,
676
766
  readVisibleEdgeInterval,
@@ -707,7 +797,7 @@ var ReactSway = (0, react.forwardRef)(function ReactSway({ ariaLabel = "Scrollab
707
797
  }, [setEdgeHoverIntensity]);
708
798
  (0, react.useEffect)(() => {
709
799
  const currentContainer = containerRef.current;
710
- if (!currentContainer) return;
800
+ if (!currentContainer || !enabled) return;
711
801
  if (draggable) {
712
802
  currentContainer.addEventListener("mousedown", handleMouseDown);
713
803
  currentContainer.addEventListener("touchstart", handleTouchStart, { passive: true });
@@ -745,6 +835,7 @@ var ReactSway = (0, react.forwardRef)(function ReactSway({ ariaLabel = "Scrollab
745
835
  detachMouseDragListeners,
746
836
  detachTouchDragListeners,
747
837
  draggable,
838
+ enabled,
748
839
  edgeHoverScroll,
749
840
  edgeHoverInputMode,
750
841
  clearEdgeHover,
@@ -755,6 +846,24 @@ var ReactSway = (0, react.forwardRef)(function ReactSway({ ariaLabel = "Scrollab
755
846
  handleWheel,
756
847
  wheelEnabled
757
848
  ]);
849
+ (0, react.useEffect)(() => {
850
+ if (enabled) return;
851
+ clearInactivityTimer();
852
+ clearEdgeHover();
853
+ detachMouseDragListeners();
854
+ detachTouchDragListeners();
855
+ touchInteractionStateRef.current = "idle";
856
+ setDraggingState(false);
857
+ velocityRef.current = 0;
858
+ autoScrollRef.current.active = autoScrollRef.current.desired;
859
+ }, [
860
+ clearEdgeHover,
861
+ clearInactivityTimer,
862
+ detachMouseDragListeners,
863
+ detachTouchDragListeners,
864
+ enabled,
865
+ setDraggingState
866
+ ]);
758
867
  (0, react.useEffect)(() => {
759
868
  window.addEventListener("resize", scheduleLoopPointRecalculation);
760
869
  return () => {
@@ -803,7 +912,7 @@ var ReactSway = (0, react.forwardRef)(function ReactSway({ ariaLabel = "Scrollab
803
912
  };
804
913
  }, [clearInactivityTimer]);
805
914
  (0, react.useEffect)(() => {
806
- if (!isTabActive || isPaused) {
915
+ if (!enabled || !isTabActive || isPaused) {
807
916
  if (animationFrameRef.current !== null) {
808
917
  cancelAnimationFrame(animationFrameRef.current);
809
918
  animationFrameRef.current = null;
@@ -812,7 +921,7 @@ var ReactSway = (0, react.forwardRef)(function ReactSway({ ariaLabel = "Scrollab
812
921
  return;
813
922
  }
814
923
  lastFrameTimeRef.current = 0;
815
- const directionMultiplier = direction === "down" || direction === "right" ? 1 : -1;
924
+ const directionMultiplier = direction === "away" || direction === "down" || direction === "right" ? 1 : -1;
816
925
  const animate = (currentTime) => {
817
926
  let deltaTime = lastFrameTimeRef.current ? (currentTime - lastFrameTimeRef.current) / MS_PER_FRAME_60FPS : 1;
818
927
  deltaTime = Math.min(deltaTime, MAX_DELTA_TIME);
@@ -823,7 +932,7 @@ var ReactSway = (0, react.forwardRef)(function ReactSway({ ariaLabel = "Scrollab
823
932
  let nextPosition = positionRef.current;
824
933
  const effectiveSpeed = prefersReducedMotion ? normalizedSpeed * REDUCED_MOTION_SPEED_FACTOR : normalizedSpeed;
825
934
  const edgeHoverMultiplier = edgeHoverScroll ? getEdgeHoverVelocityScale(edgeHoverIntensityRef.current, normalizedEdgeHoverSpeedMultiplier) : directionMultiplier;
826
- const shouldAutoScroll = (autoScrollRef.current.desired && autoScrollRef.current.active || externalEdgeHoverLeaseRef.current) && !isDraggingRef.current && (!edgeHoverScroll || edgeHoverMultiplier !== 0);
935
+ const shouldAutoScroll = (autoScrollRef.current.desired && autoScrollRef.current.active || externalEdgeHoverLeaseRef.current) && !isDraggingRef.current && !(isDepth && prefersReducedMotion) && (!edgeHoverScroll || edgeHoverMultiplier !== 0);
827
936
  if (shouldAutoScroll) {
828
937
  const autoScrollDeltaTime = edgeHoverScroll ? Math.min(deltaTime, MAX_EDGE_HOVER_DELTA_TIME) : deltaTime;
829
938
  nextPosition += edgeHoverMultiplier * effectiveSpeed * autoScrollDeltaTime;
@@ -852,6 +961,8 @@ var ReactSway = (0, react.forwardRef)(function ReactSway({ ariaLabel = "Scrollab
852
961
  commitPosition,
853
962
  direction,
854
963
  edgeHoverScroll,
964
+ enabled,
965
+ isDepth,
855
966
  isPaused,
856
967
  isTabActive,
857
968
  normalizedFriction,
@@ -884,56 +995,77 @@ var ReactSway = (0, react.forwardRef)(function ReactSway({ ariaLabel = "Scrollab
884
995
  lazyRootMargin,
885
996
  normalizedLazyThreshold
886
997
  ]);
887
- return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
998
+ return /* @__PURE__ */ react.default.createElement("section", {
888
999
  "aria-label": ariaLabel,
889
1000
  className: `react-sway-container scroller-content${className ? ` ${className}` : ""}`,
890
1001
  ref: containerRef,
891
1002
  style: {
892
- cursor: draggable ? isDraggingRef.current ? "grabbing" : "grab" : "default",
1003
+ cursor: enabled && draggable ? isDraggingRef.current ? "grabbing" : "grab" : "default",
893
1004
  display: isHorizontal ? "flex" : void 0,
894
1005
  flexDirection: isHorizontal ? "row" : void 0,
895
- height: isHorizontal ? "100%" : "max-content",
1006
+ height: isHorizontal || isDepth ? "100%" : "max-content",
896
1007
  MozUserSelect: "none",
897
1008
  msUserSelect: "none",
898
1009
  minHeight: isHorizontal ? void 0 : "100%",
899
- overflow: "hidden",
1010
+ overflow: isDepth ? "visible" : "hidden",
1011
+ perspective: isDepth ? normalizedDepthPerspective : void 0,
900
1012
  overscrollBehavior,
901
1013
  pointerEvents: "auto",
902
1014
  position: "absolute",
903
1015
  touchAction,
904
1016
  transform: "translate3d(0, 0, 0)",
1017
+ transformStyle: isDepth ? "preserve-3d" : void 0,
905
1018
  userSelect: "none",
906
1019
  WebkitUserSelect: "none",
907
1020
  width: isHorizontal ? "max-content" : "100%",
908
- willChange: "transform",
1021
+ willChange: isDepth ? void 0 : "transform",
909
1022
  zIndex: 1
910
1023
  },
911
1024
  onKeyDown: keyboard ? handleKeyDown : void 0,
912
- tabIndex: keyboard ? 0 : void 0,
913
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
914
- className: "content-group original",
915
- ref: originalGroupRef,
916
- style: isHorizontal ? {
917
- display: "flex",
918
- flex: "0 0 auto"
919
- } : void 0,
920
- children
921
- }), Array.from({ length: loopSegmentCount - 1 }, (_, duplicateIndex) => /* @__PURE__ */ (0, react.createElement)("aside", {
922
- "aria-hidden": "true",
923
- className: "content-group duplicate",
924
- "data-duplicate": "true",
925
- ...INERT_ATTRIBUTE,
926
- key: `duplicate-${duplicateIndex}`,
927
- role: "presentation",
928
- style: isHorizontal ? {
929
- display: "flex",
930
- flex: "0 0 auto",
931
- pointerEvents: "none"
932
- } : { pointerEvents: "none" }
933
- }, children))]
934
- });
1025
+ tabIndex: enabled && keyboard ? 0 : void 0
1026
+ }, /* @__PURE__ */ react.default.createElement("div", {
1027
+ className: "content-group original",
1028
+ ref: originalGroupRef,
1029
+ style: isDepth ? {
1030
+ height: "100%",
1031
+ position: "relative",
1032
+ transformStyle: "preserve-3d"
1033
+ } : isHorizontal ? {
1034
+ display: "flex",
1035
+ flex: "0 0 auto"
1036
+ } : void 0
1037
+ }, isDepth ? depthChildren.map((child, index) => {
1038
+ const { opacity, z } = getDepthItemState(0, index, depthChildren.length, normalizedDepthPerspective, normalizedDepthSpacing);
1039
+ return /* @__PURE__ */ react.default.createElement("div", {
1040
+ className: "react-sway-depth-item",
1041
+ key: typeof child === "object" && "key" in child ? child.key : index,
1042
+ style: {
1043
+ height: 0,
1044
+ left: "50%",
1045
+ opacity,
1046
+ position: "absolute",
1047
+ top: "50%",
1048
+ transform: `translate3d(0, 0, ${z}px)`,
1049
+ width: 0,
1050
+ willChange: "transform, opacity"
1051
+ }
1052
+ }, child);
1053
+ }) : children), Array.from({ length: isDepth ? 0 : loopSegmentCount - 1 }, (_, duplicateIndex) => /* @__PURE__ */ react.default.createElement("aside", {
1054
+ "aria-hidden": "true",
1055
+ className: "content-group duplicate",
1056
+ "data-duplicate": "true",
1057
+ ...INERT_ATTRIBUTE,
1058
+ key: `duplicate-${duplicateIndex}`,
1059
+ role: "presentation",
1060
+ style: isHorizontal ? {
1061
+ display: "flex",
1062
+ flex: "0 0 auto",
1063
+ pointerEvents: "none"
1064
+ } : { pointerEvents: "none" }
1065
+ }, children)));
935
1066
  });
936
- //#endregion
1067
+ exports.DEFAULT_DEPTH_PERSPECTIVE = DEFAULT_DEPTH_PERSPECTIVE;
1068
+ exports.DEFAULT_DEPTH_SPACING = DEFAULT_DEPTH_SPACING;
937
1069
  exports.DEFAULT_EDGE_HOVER_SIZE = DEFAULT_EDGE_HOVER_SIZE;
938
1070
  exports.DEFAULT_EDGE_HOVER_SPEED_MULTIPLIER = DEFAULT_EDGE_HOVER_SPEED_MULTIPLIER;
939
1071
  exports.ReactSway = ReactSway;
package/dist/index.d.ts CHANGED
@@ -2,5 +2,6 @@
2
2
  * Public package entrypoint for ReactSway exports.
3
3
  */
4
4
  export { default as ReactSway } from './ReactSway';
5
+ export { DEFAULT_DEPTH_PERSPECTIVE, DEFAULT_DEPTH_SPACING, } from './depth-position';
5
6
  export { DEFAULT_EDGE_HOVER_SIZE, DEFAULT_EDGE_HOVER_SPEED_MULTIPLIER, } from './edge-hover-velocity';
6
7
  export type { ReactSwayHandle, ReactSwayProps, SwayAxis, SwayDirection, SwayEdgeHoverInputMode, SwayWheelMode, } from './ReactSway';
package/dist/index.js CHANGED
@@ -1,9 +1,25 @@
1
- import { createElement, forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState, version } 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. */
@@ -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;
@@ -127,11 +141,7 @@ function getOwnedWheelDelta(event, container, isHorizontal, wheelMode) {
127
141
  if (wheelMode === "axis" && absDeltaX > absDeltaY) return null;
128
142
  return deltaY;
129
143
  }
130
- //#endregion
131
- //#region src/ReactSway.tsx
132
- /**
133
- * Core ReactSway component implementing axis-aware infinite scrolling interactions.
134
- */
144
+ // src/ReactSway.tsx
135
145
  /** Velocity applied per arrow key press (pixels). */
136
146
  var ARROW_KEY_VELOCITY = 15;
137
147
  /** Default friction coefficient applied to velocity each frame. */
@@ -185,11 +195,11 @@ function getVisibleEdgeInterval(rect, isHorizontal) {
185
195
  * Renders children in a continuously looping scroll area with support for
186
196
  * auto-scrolling, mouse drag, touch swipe, wheel, keyboard interactions,
187
197
  * and optional edge-hover scrolling.
188
- * Content is duplicated to create a seamless loop effect. Duplicate content
198
+ * Planar content is duplicated to create a seamless loop effect. Duplicate content
189
199
  * is wrapped in `<aside>` elements with `aria-hidden="true"` for accessibility.
190
200
  *
191
- * Respects `prefers-reduced-motion: reduce` by lowering auto-scroll speed
192
- * and disabling momentum effects.
201
+ * Reduced motion stops depth auto-scroll, lowers planar auto-scroll speed,
202
+ * and disables momentum effects.
193
203
  *
194
204
  * @example
195
205
  * ```tsx
@@ -199,8 +209,14 @@ function getVisibleEdgeInterval(rect, isHorizontal) {
199
209
  * </ReactSway>
200
210
  * ```
201
211
  */
202
- 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) {
203
- 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);
204
220
  const normalizedEdgeHoverSize = useMemo(() => Number.isFinite(edgeHoverSize) ? Math.max(1, edgeHoverSize) : 96, [edgeHoverSize]);
205
221
  const normalizedEdgeHoverSpeedMultiplier = useMemo(() => normalizeEdgeHoverSpeedMultiplier(edgeHoverSpeedMultiplier), [edgeHoverSpeedMultiplier]);
206
222
  const normalizedFriction = useMemo(() => Number.isFinite(friction) ? Math.min(Math.max(friction, 0), 1) : DEFAULT_FRICTION, [friction]);
@@ -208,17 +224,22 @@ var ReactSway = forwardRef(function ReactSway({ ariaLabel = "Scrollable content"
208
224
  const normalizedResumeDelay = useMemo(() => Number.isFinite(resumeDelay) ? Math.max(0, resumeDelay) : DEFAULT_RESUME_DELAY, [resumeDelay]);
209
225
  const normalizedSpeed = useMemo(() => Number.isFinite(speed) ? Math.max(0, speed) : DEFAULT_SPEED, [speed]);
210
226
  const touchAction = useMemo(() => {
211
- if (!draggable) return "auto";
227
+ if (!enabled || !draggable) return "auto";
212
228
  return isHorizontal ? "pan-y" : "pan-x";
213
- }, [draggable, isHorizontal]);
214
- const overscrollBehavior = wheelEnabled && wheelMode === "capture" ? "contain" : "auto";
229
+ }, [
230
+ draggable,
231
+ enabled,
232
+ isHorizontal
233
+ ]);
234
+ const overscrollBehavior = enabled && wheelEnabled && wheelMode === "capture" ? "contain" : "auto";
215
235
  const [isPaused, setIsPaused] = useState(false);
216
236
  const [isTabActive, setIsTabActive] = useState(() => typeof document === "undefined" || !document.hidden);
217
237
  const [loopSegmentCount, setLoopSegmentCount] = useState(MIN_LOOP_SEGMENTS);
218
- const [prefersReducedMotion, setPrefersReducedMotion] = useState(() => {
238
+ const [systemReducedMotion, setSystemReducedMotion] = useState(() => {
219
239
  if (typeof window === "undefined") return false;
220
240
  return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
221
241
  });
242
+ const prefersReducedMotion = systemReducedMotion || reducedMotion;
222
243
  const animationFrameRef = useRef(null);
223
244
  const animationLoopRef = useRef(null);
224
245
  const autoScrollRef = useRef({
@@ -265,7 +286,7 @@ var ReactSway = forwardRef(function ReactSway({ ariaLabel = "Scrollable content"
265
286
  if (typeof window === "undefined") return;
266
287
  const mediaQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
267
288
  const handleChange = (e) => {
268
- setPrefersReducedMotion(e.matches);
289
+ setSystemReducedMotion(e.matches);
269
290
  };
270
291
  mediaQuery.addEventListener("change", handleChange);
271
292
  return () => {
@@ -305,11 +326,28 @@ var ReactSway = forwardRef(function ReactSway({ ariaLabel = "Scrollable content"
305
326
  }, [isHorizontal]);
306
327
  const renderPosition = useCallback((rawPosition) => {
307
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
+ }
308
341
  const currentLoopPoint = loopPointRef.current;
309
342
  let visualPosition = rawPosition % (currentLoopPoint || 1);
310
343
  if (visualPosition > 0 && currentLoopPoint > 0) visualPosition -= currentLoopPoint;
311
344
  containerRef.current.style.transform = isHorizontal ? `translate3d(${visualPosition}px, 0, 0)` : `translate3d(0, ${visualPosition}px, 0)`;
312
- }, [isHorizontal]);
345
+ }, [
346
+ isDepth,
347
+ isHorizontal,
348
+ normalizedDepthPerspective,
349
+ normalizedDepthSpacing
350
+ ]);
313
351
  const commitPosition = useCallback((nextPosition) => {
314
352
  if (positionRef.current === nextPosition) return;
315
353
  positionRef.current = nextPosition;
@@ -330,11 +368,14 @@ var ReactSway = forwardRef(function ReactSway({ ariaLabel = "Scrollable content"
330
368
  }, [draggable]);
331
369
  const recalculateLoopPoint = useCallback(() => {
332
370
  if (!containerRef.current || !originalGroupRef.current) return;
333
- const originalContentSize = isHorizontal ? originalGroupRef.current.scrollWidth : originalGroupRef.current.scrollHeight;
334
- 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
+ }
335
376
  const viewport = containerRef.current.parentElement ?? containerRef.current;
336
377
  const viewportSize = isHorizontal ? Math.max(viewport.clientWidth, 1) : Math.max(viewport.clientHeight, 1);
337
- 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);
338
379
  const nextLoopPoint = originalContentSize;
339
380
  const previousMeasurement = lastLoopMeasurementRef.current;
340
381
  const nextMeasurement = {
@@ -347,7 +388,13 @@ var ReactSway = forwardRef(function ReactSway({ ariaLabel = "Scrollable content"
347
388
  setLoopSegmentCount((previousLoopSegmentCount) => previousLoopSegmentCount === nextLoopSegmentCount ? previousLoopSegmentCount : nextLoopSegmentCount);
348
389
  loopPointRef.current = nextLoopPoint;
349
390
  renderPosition(positionRef.current);
350
- }, [isHorizontal, renderPosition]);
391
+ }, [
392
+ depthChildren.length,
393
+ isDepth,
394
+ isHorizontal,
395
+ normalizedDepthSpacing,
396
+ renderPosition
397
+ ]);
351
398
  useEffect(() => {
352
399
  autoScrollRef.current.desired = autoScroll;
353
400
  if (!autoScroll) {
@@ -372,6 +419,7 @@ var ReactSway = forwardRef(function ReactSway({ ariaLabel = "Scrollable content"
372
419
  scheduleLoopPointRecalculation();
373
420
  }, [
374
421
  children,
422
+ isDepth,
375
423
  isHorizontal,
376
424
  scheduleLoopPointRecalculation
377
425
  ]);
@@ -426,7 +474,14 @@ var ReactSway = forwardRef(function ReactSway({ ariaLabel = "Scrollable content"
426
474
  touchDragListenerCleanupRef.current = null;
427
475
  }, []);
428
476
  const handleKeyDown = useCallback((e) => {
429
- if (!keyboard || isInteractiveTarget(e.target)) 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
+ }
430
485
  switch (e.key) {
431
486
  case " ":
432
487
  e.preventDefault();
@@ -477,12 +532,15 @@ var ReactSway = forwardRef(function ReactSway({ ariaLabel = "Scrollable content"
477
532
  startAnimationLoop();
478
533
  }, [
479
534
  commitPosition,
535
+ enabled,
480
536
  isHorizontal,
481
537
  keyboard,
482
538
  pauseAutoScroll,
539
+ prefersReducedMotion,
483
540
  scheduleAutoScrollResume,
484
541
  startAnimationLoop,
485
- togglePause
542
+ togglePause,
543
+ wrapPosition
486
544
  ]);
487
545
  const handleMouseMove = useCallback((e) => {
488
546
  if (!isDraggingRef.current) return;
@@ -540,7 +598,7 @@ var ReactSway = forwardRef(function ReactSway({ ariaLabel = "Scrollable content"
540
598
  handleMouseUp
541
599
  ]);
542
600
  const handleMouseDown = useCallback((e) => {
543
- if (!draggable || e.button !== 0 || isInteractiveTarget(e.target)) return;
601
+ if (!enabled || !draggable || e.button !== 0 || isInteractiveTarget(e.target)) return;
544
602
  e.preventDefault();
545
603
  containerRef.current?.focus();
546
604
  setDraggingState(true);
@@ -553,6 +611,7 @@ var ReactSway = forwardRef(function ReactSway({ ariaLabel = "Scrollable content"
553
611
  }, [
554
612
  attachMouseDragListeners,
555
613
  draggable,
614
+ enabled,
556
615
  pauseAutoScroll,
557
616
  setDraggingState,
558
617
  startAnimationLoop
@@ -576,6 +635,7 @@ var ReactSway = forwardRef(function ReactSway({ ariaLabel = "Scrollable content"
576
635
  if (touchInteractionStateRef.current === "idle" || touchInteractionStateRef.current === "ignored") return;
577
636
  if (e.touches.length !== 1) return;
578
637
  const touch = e.touches[0];
638
+ if (!touch) return;
579
639
  if (touchInteractionStateRef.current === "pending") {
580
640
  const totalDeltaX = touch.clientX - touchStartXRef.current;
581
641
  const totalDeltaY = touch.clientY - touchStartYRef.current;
@@ -620,18 +680,24 @@ var ReactSway = forwardRef(function ReactSway({ ariaLabel = "Scrollable content"
620
680
  handleTouchMove
621
681
  ]);
622
682
  const handleTouchStart = useCallback((e) => {
623
- if (!draggable || e.touches.length !== 1 || isInteractiveTarget(e.target)) return;
683
+ if (!enabled || !draggable || e.touches.length !== 1 || isInteractiveTarget(e.target)) return;
684
+ const touch = e.touches[0];
685
+ if (!touch) return;
624
686
  containerRef.current?.focus();
625
687
  touchInteractionStateRef.current = "pending";
626
- lastTouchXRef.current = e.touches[0].clientX;
627
- lastTouchYRef.current = e.touches[0].clientY;
628
- touchStartXRef.current = e.touches[0].clientX;
629
- 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;
630
692
  velocityRef.current = 0;
631
693
  attachTouchDragListeners();
632
- }, [attachTouchDragListeners, draggable]);
694
+ }, [
695
+ attachTouchDragListeners,
696
+ draggable,
697
+ enabled
698
+ ]);
633
699
  const handleWheel = useCallback((e) => {
634
- if (!wheelEnabled || e.defaultPrevented) return false;
700
+ if (!enabled || !wheelEnabled || e.defaultPrevented) return false;
635
701
  const currentContainer = e.currentTarget instanceof HTMLElement ? e.currentTarget : containerRef.current;
636
702
  if (!currentContainer) return false;
637
703
  const ownedDelta = getOwnedWheelDelta(e, currentContainer, isHorizontal, wheelMode);
@@ -648,6 +714,7 @@ var ReactSway = forwardRef(function ReactSway({ ariaLabel = "Scrollable content"
648
714
  return true;
649
715
  }, [
650
716
  commitPosition,
717
+ enabled,
651
718
  isHorizontal,
652
719
  pauseAutoScroll,
653
720
  scheduleAutoScrollResume,
@@ -657,7 +724,7 @@ var ReactSway = forwardRef(function ReactSway({ ariaLabel = "Scrollable content"
657
724
  wrapPosition
658
725
  ]);
659
726
  const applyEdgeHoverPoint = useCallback((point) => {
660
- if (!edgeHoverScroll) return false;
727
+ if (!enabled || !edgeHoverScroll) return false;
661
728
  const viewport = containerRef.current?.parentElement ?? containerRef.current;
662
729
  if (!viewport) return false;
663
730
  const visibleInterval = readVisibleEdgeInterval(viewport);
@@ -670,6 +737,7 @@ var ReactSway = forwardRef(function ReactSway({ ariaLabel = "Scrollable content"
670
737
  return true;
671
738
  }, [
672
739
  edgeHoverScroll,
740
+ enabled,
673
741
  isHorizontal,
674
742
  normalizedEdgeHoverSize,
675
743
  readVisibleEdgeInterval,
@@ -706,7 +774,7 @@ var ReactSway = forwardRef(function ReactSway({ ariaLabel = "Scrollable content"
706
774
  }, [setEdgeHoverIntensity]);
707
775
  useEffect(() => {
708
776
  const currentContainer = containerRef.current;
709
- if (!currentContainer) return;
777
+ if (!currentContainer || !enabled) return;
710
778
  if (draggable) {
711
779
  currentContainer.addEventListener("mousedown", handleMouseDown);
712
780
  currentContainer.addEventListener("touchstart", handleTouchStart, { passive: true });
@@ -744,6 +812,7 @@ var ReactSway = forwardRef(function ReactSway({ ariaLabel = "Scrollable content"
744
812
  detachMouseDragListeners,
745
813
  detachTouchDragListeners,
746
814
  draggable,
815
+ enabled,
747
816
  edgeHoverScroll,
748
817
  edgeHoverInputMode,
749
818
  clearEdgeHover,
@@ -754,6 +823,24 @@ var ReactSway = forwardRef(function ReactSway({ ariaLabel = "Scrollable content"
754
823
  handleWheel,
755
824
  wheelEnabled
756
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
+ ]);
757
844
  useEffect(() => {
758
845
  window.addEventListener("resize", scheduleLoopPointRecalculation);
759
846
  return () => {
@@ -802,7 +889,7 @@ var ReactSway = forwardRef(function ReactSway({ ariaLabel = "Scrollable content"
802
889
  };
803
890
  }, [clearInactivityTimer]);
804
891
  useEffect(() => {
805
- if (!isTabActive || isPaused) {
892
+ if (!enabled || !isTabActive || isPaused) {
806
893
  if (animationFrameRef.current !== null) {
807
894
  cancelAnimationFrame(animationFrameRef.current);
808
895
  animationFrameRef.current = null;
@@ -811,7 +898,7 @@ var ReactSway = forwardRef(function ReactSway({ ariaLabel = "Scrollable content"
811
898
  return;
812
899
  }
813
900
  lastFrameTimeRef.current = 0;
814
- const directionMultiplier = direction === "down" || direction === "right" ? 1 : -1;
901
+ const directionMultiplier = direction === "away" || direction === "down" || direction === "right" ? 1 : -1;
815
902
  const animate = (currentTime) => {
816
903
  let deltaTime = lastFrameTimeRef.current ? (currentTime - lastFrameTimeRef.current) / MS_PER_FRAME_60FPS : 1;
817
904
  deltaTime = Math.min(deltaTime, MAX_DELTA_TIME);
@@ -822,7 +909,7 @@ var ReactSway = forwardRef(function ReactSway({ ariaLabel = "Scrollable content"
822
909
  let nextPosition = positionRef.current;
823
910
  const effectiveSpeed = prefersReducedMotion ? normalizedSpeed * REDUCED_MOTION_SPEED_FACTOR : normalizedSpeed;
824
911
  const edgeHoverMultiplier = edgeHoverScroll ? getEdgeHoverVelocityScale(edgeHoverIntensityRef.current, normalizedEdgeHoverSpeedMultiplier) : directionMultiplier;
825
- 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);
826
913
  if (shouldAutoScroll) {
827
914
  const autoScrollDeltaTime = edgeHoverScroll ? Math.min(deltaTime, MAX_EDGE_HOVER_DELTA_TIME) : deltaTime;
828
915
  nextPosition += edgeHoverMultiplier * effectiveSpeed * autoScrollDeltaTime;
@@ -851,6 +938,8 @@ var ReactSway = forwardRef(function ReactSway({ ariaLabel = "Scrollable content"
851
938
  commitPosition,
852
939
  direction,
853
940
  edgeHoverScroll,
941
+ enabled,
942
+ isDepth,
854
943
  isPaused,
855
944
  isTabActive,
856
945
  normalizedFriction,
@@ -883,54 +972,73 @@ var ReactSway = forwardRef(function ReactSway({ ariaLabel = "Scrollable content"
883
972
  lazyRootMargin,
884
973
  normalizedLazyThreshold
885
974
  ]);
886
- return /* @__PURE__ */ jsxs("section", {
975
+ return /* @__PURE__ */ React.createElement("section", {
887
976
  "aria-label": ariaLabel,
888
977
  className: `react-sway-container scroller-content${className ? ` ${className}` : ""}`,
889
978
  ref: containerRef,
890
979
  style: {
891
- cursor: draggable ? isDraggingRef.current ? "grabbing" : "grab" : "default",
980
+ cursor: enabled && draggable ? isDraggingRef.current ? "grabbing" : "grab" : "default",
892
981
  display: isHorizontal ? "flex" : void 0,
893
982
  flexDirection: isHorizontal ? "row" : void 0,
894
- height: isHorizontal ? "100%" : "max-content",
983
+ height: isHorizontal || isDepth ? "100%" : "max-content",
895
984
  MozUserSelect: "none",
896
985
  msUserSelect: "none",
897
986
  minHeight: isHorizontal ? void 0 : "100%",
898
- overflow: "hidden",
987
+ overflow: isDepth ? "visible" : "hidden",
988
+ perspective: isDepth ? normalizedDepthPerspective : void 0,
899
989
  overscrollBehavior,
900
990
  pointerEvents: "auto",
901
991
  position: "absolute",
902
992
  touchAction,
903
993
  transform: "translate3d(0, 0, 0)",
994
+ transformStyle: isDepth ? "preserve-3d" : void 0,
904
995
  userSelect: "none",
905
996
  WebkitUserSelect: "none",
906
997
  width: isHorizontal ? "max-content" : "100%",
907
- willChange: "transform",
998
+ willChange: isDepth ? void 0 : "transform",
908
999
  zIndex: 1
909
1000
  },
910
1001
  onKeyDown: keyboard ? handleKeyDown : void 0,
911
- tabIndex: keyboard ? 0 : void 0,
912
- children: [/* @__PURE__ */ jsx("div", {
913
- className: "content-group original",
914
- ref: originalGroupRef,
915
- style: isHorizontal ? {
916
- display: "flex",
917
- flex: "0 0 auto"
918
- } : void 0,
919
- children
920
- }), Array.from({ length: loopSegmentCount - 1 }, (_, duplicateIndex) => /* @__PURE__ */ createElement("aside", {
921
- "aria-hidden": "true",
922
- className: "content-group duplicate",
923
- "data-duplicate": "true",
924
- ...INERT_ATTRIBUTE,
925
- key: `duplicate-${duplicateIndex}`,
926
- role: "presentation",
927
- style: isHorizontal ? {
928
- display: "flex",
929
- flex: "0 0 auto",
930
- pointerEvents: "none"
931
- } : { pointerEvents: "none" }
932
- }, children))]
933
- });
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)));
934
1043
  });
935
- //#endregion
936
- 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",
@@ -20,14 +20,14 @@
20
20
  "@testing-library/react": "^16.3.3",
21
21
  "@types/react": "^19.3.0",
22
22
  "@types/react-dom": "^19.3.0",
23
- "eslint": "^10.13.0",
23
+ "eslint": "^10.14.0",
24
24
  "eslint-plugin-react-hooks": "^7.1.1",
25
- "jsdom": "^30.1.3",
25
+ "jsdom": "^31.0.0",
26
26
  "react": "^19.3.1",
27
27
  "react-dom": "^19.3.1",
28
- "typescript": "^6.0.3",
29
- "vite": "^8.3.4",
30
- "vitest": "^5.0.5"
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.3"
79
+ "version": "0.5.0"
78
80
  }