react-sway 0.2.1 → 0.2.2

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/README.md CHANGED
@@ -15,7 +15,7 @@ It works by duplicating your content to create a seamless loop and uses CSS tran
15
15
  * **User Friendly Interactions:**
16
16
  * Click and drag to scroll.
17
17
  * Swipe on touch devices.
18
- * Mouse wheel support with velocity capping.
18
+ * Mouse wheel support with delta-mode normalization and velocity capping.
19
19
  * Keyboard controls: Spacebar to pause/resume, ArrowUp/ArrowDown to scroll, Home/End to jump.
20
20
  * **Responsive:** Adjusts to window resizing with debounced recalculation.
21
21
  * **Lazy Visibility Detection:** Add a `content-item` class to your child elements, and `react-sway` automatically uses an IntersectionObserver to add a `.visible` class when they enter the viewport. Useful for triggering CSS animations or deferred rendering. Configurable via `lazy`, `lazyRootMargin`, and `lazyThreshold` props.
package/dist/index.cjs CHANGED
@@ -39,7 +39,37 @@ var MAX_VELOCITY = 150;
39
39
  var MS_PER_FRAME_60FPS = 16.667;
40
40
  var REDUCED_MOTION_SPEED_FACTOR = 0.25;
41
41
  var RESIZE_DEBOUNCE_MS = 150;
42
- var WHEEL_VELOCITY_MULTIPLIER = 0.3;
42
+ var WHEEL_LINE_HEIGHT_FALLBACK_PX = 16;
43
+ var WHEEL_MODE_LINE = 1;
44
+ var WHEEL_MODE_PAGE = 2;
45
+ var WHEEL_PIXEL_MIN_DELTA_PX = 48;
46
+ var WHEEL_VELOCITY_MULTIPLIER = 0.14;
47
+ var WHEEL_IMMEDIATE_DELTA_FACTOR = 0.14;
48
+ var WHEEL_LEGACY_NOTCH_DELTA = 120;
49
+ function getLegacyWheelPixelDeltaY(event) {
50
+ const { wheelDelta, wheelDeltaY } = event;
51
+ const legacyWheelDelta = typeof wheelDeltaY === "number" ? wheelDeltaY : wheelDelta;
52
+ if (typeof legacyWheelDelta !== "number" || !Number.isFinite(legacyWheelDelta)) {
53
+ return 0;
54
+ }
55
+ return -(legacyWheelDelta / WHEEL_LEGACY_NOTCH_DELTA) * WHEEL_PIXEL_MIN_DELTA_PX;
56
+ }
57
+ function normalizeWheelDeltaY(event, container) {
58
+ if (event.deltaMode === WHEEL_MODE_LINE) {
59
+ const computedLineHeight = Number.parseFloat(window.getComputedStyle(container).lineHeight);
60
+ const lineHeight = Number.isFinite(computedLineHeight) ? computedLineHeight : WHEEL_LINE_HEIGHT_FALLBACK_PX;
61
+ return event.deltaY * lineHeight;
62
+ }
63
+ if (event.deltaMode === WHEEL_MODE_PAGE) {
64
+ const pageHeight = Math.max(container.clientHeight, window.innerHeight, 1);
65
+ return event.deltaY * pageHeight;
66
+ }
67
+ const legacyWheelPixelDeltaY = getLegacyWheelPixelDeltaY(event);
68
+ if (Math.abs(event.deltaY) < WHEEL_PIXEL_MIN_DELTA_PX && Math.abs(legacyWheelPixelDeltaY) >= WHEEL_PIXEL_MIN_DELTA_PX) {
69
+ return legacyWheelPixelDeltaY;
70
+ }
71
+ return event.deltaY;
72
+ }
43
73
  function ReactSway({
44
74
  autoScroll = true,
45
75
  children,
@@ -292,12 +322,18 @@ function ReactSway({
292
322
  }, [draggable, pauseAutoScroll]);
293
323
  const handleWheel = (0, import_react.useCallback)((e) => {
294
324
  if (!wheelEnabled) return;
325
+ const currentContainer = e.currentTarget instanceof HTMLElement ? e.currentTarget : containerRef.current;
326
+ if (!currentContainer) return;
295
327
  e.preventDefault();
296
- velocityRef.current -= e.deltaY * WHEEL_VELOCITY_MULTIPLIER;
328
+ const normalizedDeltaY = normalizeWheelDeltaY(e, currentContainer);
329
+ const wheelDelta = -normalizedDeltaY;
330
+ const nextPosition = wrapPosition(positionRef.current + wheelDelta * WHEEL_IMMEDIATE_DELTA_FACTOR);
331
+ commitPosition(nextPosition);
332
+ velocityRef.current += wheelDelta * WHEEL_VELOCITY_MULTIPLIER;
297
333
  velocityRef.current = Math.max(-MAX_VELOCITY, Math.min(MAX_VELOCITY, velocityRef.current));
298
334
  pauseAutoScroll();
299
335
  scheduleAutoScrollResume();
300
- }, [pauseAutoScroll, scheduleAutoScrollResume, wheelEnabled]);
336
+ }, [commitPosition, pauseAutoScroll, scheduleAutoScrollResume, wheelEnabled, wrapPosition]);
301
337
  (0, import_react.useEffect)(() => {
302
338
  const currentContainer = containerRef.current;
303
339
  if (!currentContainer) return;
package/dist/index.js CHANGED
@@ -13,7 +13,37 @@ var MAX_VELOCITY = 150;
13
13
  var MS_PER_FRAME_60FPS = 16.667;
14
14
  var REDUCED_MOTION_SPEED_FACTOR = 0.25;
15
15
  var RESIZE_DEBOUNCE_MS = 150;
16
- var WHEEL_VELOCITY_MULTIPLIER = 0.3;
16
+ var WHEEL_LINE_HEIGHT_FALLBACK_PX = 16;
17
+ var WHEEL_MODE_LINE = 1;
18
+ var WHEEL_MODE_PAGE = 2;
19
+ var WHEEL_PIXEL_MIN_DELTA_PX = 48;
20
+ var WHEEL_VELOCITY_MULTIPLIER = 0.14;
21
+ var WHEEL_IMMEDIATE_DELTA_FACTOR = 0.14;
22
+ var WHEEL_LEGACY_NOTCH_DELTA = 120;
23
+ function getLegacyWheelPixelDeltaY(event) {
24
+ const { wheelDelta, wheelDeltaY } = event;
25
+ const legacyWheelDelta = typeof wheelDeltaY === "number" ? wheelDeltaY : wheelDelta;
26
+ if (typeof legacyWheelDelta !== "number" || !Number.isFinite(legacyWheelDelta)) {
27
+ return 0;
28
+ }
29
+ return -(legacyWheelDelta / WHEEL_LEGACY_NOTCH_DELTA) * WHEEL_PIXEL_MIN_DELTA_PX;
30
+ }
31
+ function normalizeWheelDeltaY(event, container) {
32
+ if (event.deltaMode === WHEEL_MODE_LINE) {
33
+ const computedLineHeight = Number.parseFloat(window.getComputedStyle(container).lineHeight);
34
+ const lineHeight = Number.isFinite(computedLineHeight) ? computedLineHeight : WHEEL_LINE_HEIGHT_FALLBACK_PX;
35
+ return event.deltaY * lineHeight;
36
+ }
37
+ if (event.deltaMode === WHEEL_MODE_PAGE) {
38
+ const pageHeight = Math.max(container.clientHeight, window.innerHeight, 1);
39
+ return event.deltaY * pageHeight;
40
+ }
41
+ const legacyWheelPixelDeltaY = getLegacyWheelPixelDeltaY(event);
42
+ if (Math.abs(event.deltaY) < WHEEL_PIXEL_MIN_DELTA_PX && Math.abs(legacyWheelPixelDeltaY) >= WHEEL_PIXEL_MIN_DELTA_PX) {
43
+ return legacyWheelPixelDeltaY;
44
+ }
45
+ return event.deltaY;
46
+ }
17
47
  function ReactSway({
18
48
  autoScroll = true,
19
49
  children,
@@ -266,12 +296,18 @@ function ReactSway({
266
296
  }, [draggable, pauseAutoScroll]);
267
297
  const handleWheel = useCallback((e) => {
268
298
  if (!wheelEnabled) return;
299
+ const currentContainer = e.currentTarget instanceof HTMLElement ? e.currentTarget : containerRef.current;
300
+ if (!currentContainer) return;
269
301
  e.preventDefault();
270
- velocityRef.current -= e.deltaY * WHEEL_VELOCITY_MULTIPLIER;
302
+ const normalizedDeltaY = normalizeWheelDeltaY(e, currentContainer);
303
+ const wheelDelta = -normalizedDeltaY;
304
+ const nextPosition = wrapPosition(positionRef.current + wheelDelta * WHEEL_IMMEDIATE_DELTA_FACTOR);
305
+ commitPosition(nextPosition);
306
+ velocityRef.current += wheelDelta * WHEEL_VELOCITY_MULTIPLIER;
271
307
  velocityRef.current = Math.max(-MAX_VELOCITY, Math.min(MAX_VELOCITY, velocityRef.current));
272
308
  pauseAutoScroll();
273
309
  scheduleAutoScrollResume();
274
- }, [pauseAutoScroll, scheduleAutoScrollResume, wheelEnabled]);
310
+ }, [commitPosition, pauseAutoScroll, scheduleAutoScrollResume, wheelEnabled, wrapPosition]);
275
311
  useEffect(() => {
276
312
  const currentContainer = containerRef.current;
277
313
  if (!currentContainer) return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "author": {
3
- "name": "Mehdy Lafitte",
3
+ "name": "Lafitte Mehdy",
4
4
  "url": "https://github.com/lafittemehdy"
5
5
  },
6
6
  "bugs": {
@@ -8,19 +8,21 @@
8
8
  },
9
9
  "description": "A React component for smooth infinite scrolling, designed for creating engaging, continuous content streams with minimal configuration.",
10
10
  "devDependencies": {
11
+ "@emnapi/core": "^1.10.0",
12
+ "@emnapi/runtime": "^1.10.0",
11
13
  "@testing-library/jest-dom": "^6.9.1",
12
14
  "@testing-library/react": "^16.3.2",
13
15
  "@types/react": "^19.2.14",
14
16
  "@types/react-dom": "^19.2.3",
15
17
  "eslint": "^9.39.4",
16
18
  "eslint-plugin-react": "^7.37.5",
17
- "eslint-plugin-react-hooks": "^7.0.1",
18
- "jsdom": "^28.1.0",
19
- "react": "^19.2.4",
20
- "react-dom": "^19.2.4",
19
+ "eslint-plugin-react-hooks": "^7.1.1",
20
+ "jsdom": "^29.1.1",
21
+ "react": "^19.2.6",
22
+ "react-dom": "^19.2.6",
21
23
  "tsup": "^8.5.1",
22
- "typescript": "^5.9.3",
23
- "vitest": "^4.1.0"
24
+ "typescript": "^6.0.3",
25
+ "vitest": "^4.1.6"
24
26
  },
25
27
  "files": [
26
28
  "dist",
@@ -49,7 +51,8 @@
49
51
  "react-dom": ">=16.8.0"
50
52
  },
51
53
  "publishConfig": {
52
- "access": "public"
54
+ "access": "public",
55
+ "provenance": true
53
56
  },
54
57
  "repository": {
55
58
  "type": "git",
@@ -65,5 +68,5 @@
65
68
  },
66
69
  "type": "module",
67
70
  "types": "dist/index.d.ts",
68
- "version": "0.2.1"
71
+ "version": "0.2.2"
69
72
  }
package/src/ReactSway.tsx CHANGED
@@ -36,8 +36,74 @@ const REDUCED_MOTION_SPEED_FACTOR = 0.25;
36
36
  /** Debounce delay in milliseconds for ResizeObserver callbacks. */
37
37
  const RESIZE_DEBOUNCE_MS = 150;
38
38
 
39
+ /** Fallback pixel height for wheel events reported in line units. */
40
+ const WHEEL_LINE_HEIGHT_FALLBACK_PX = 16;
41
+
42
+ /** WheelEvent deltaMode value for line-based deltas. */
43
+ const WHEEL_MODE_LINE = 1;
44
+
45
+ /** WheelEvent deltaMode value for page-based deltas. */
46
+ const WHEEL_MODE_PAGE = 2;
47
+
48
+ /** Minimum pixel impulse for discrete wheel hardware reporting tiny pixel deltas. */
49
+ const WHEEL_PIXEL_MIN_DELTA_PX = 48;
50
+
39
51
  /** Multiplier applied to wheel deltaY to convert to scroll velocity. */
40
- const WHEEL_VELOCITY_MULTIPLIER = 0.3;
52
+ const WHEEL_VELOCITY_MULTIPLIER = 0.14;
53
+
54
+ /** Small immediate wheel movement used to keep input responsive without jumping. */
55
+ const WHEEL_IMMEDIATE_DELTA_FACTOR = 0.14;
56
+
57
+ /** Legacy wheelDelta magnitude that usually represents one physical wheel notch. */
58
+ const WHEEL_LEGACY_NOTCH_DELTA = 120;
59
+
60
+ interface WheelEventWithLegacyDelta extends globalThis.WheelEvent {
61
+ wheelDelta?: number;
62
+ wheelDeltaY?: number;
63
+ }
64
+
65
+ function getLegacyWheelPixelDeltaY(event: globalThis.WheelEvent) {
66
+ const { wheelDelta, wheelDeltaY } = event as WheelEventWithLegacyDelta;
67
+ const legacyWheelDelta = typeof wheelDeltaY === 'number' ? wheelDeltaY : wheelDelta;
68
+
69
+ if (typeof legacyWheelDelta !== 'number' || !Number.isFinite(legacyWheelDelta)) {
70
+ return 0;
71
+ }
72
+
73
+ return -(legacyWheelDelta / WHEEL_LEGACY_NOTCH_DELTA) * WHEEL_PIXEL_MIN_DELTA_PX;
74
+ }
75
+
76
+ /**
77
+ * Converts wheel deltas to pixels so mouse wheels, touchpads, and page-wheel
78
+ * devices feed the same Sway velocity system.
79
+ */
80
+ function normalizeWheelDeltaY(event: globalThis.WheelEvent, container: HTMLElement) {
81
+ if (event.deltaMode === WHEEL_MODE_LINE) {
82
+ const computedLineHeight = Number.parseFloat(window.getComputedStyle(container).lineHeight);
83
+ const lineHeight = Number.isFinite(computedLineHeight)
84
+ ? computedLineHeight
85
+ : WHEEL_LINE_HEIGHT_FALLBACK_PX;
86
+
87
+ return event.deltaY * lineHeight;
88
+ }
89
+
90
+ if (event.deltaMode === WHEEL_MODE_PAGE) {
91
+ const pageHeight = Math.max(container.clientHeight, window.innerHeight, 1);
92
+
93
+ return event.deltaY * pageHeight;
94
+ }
95
+
96
+ const legacyWheelPixelDeltaY = getLegacyWheelPixelDeltaY(event);
97
+
98
+ if (
99
+ Math.abs(event.deltaY) < WHEEL_PIXEL_MIN_DELTA_PX &&
100
+ Math.abs(legacyWheelPixelDeltaY) >= WHEEL_PIXEL_MIN_DELTA_PX
101
+ ) {
102
+ return legacyWheelPixelDeltaY;
103
+ }
104
+
105
+ return event.deltaY;
106
+ }
41
107
 
42
108
  /**
43
109
  * Props for the ReactSway infinite scrolling component.
@@ -382,12 +448,19 @@ function ReactSway({
382
448
 
383
449
  const handleWheel = useCallback((e: globalThis.WheelEvent) => {
384
450
  if (!wheelEnabled) return;
451
+ const currentContainer = e.currentTarget instanceof HTMLElement ? e.currentTarget : containerRef.current;
452
+ if (!currentContainer) return;
453
+
385
454
  e.preventDefault();
386
- velocityRef.current -= e.deltaY * WHEEL_VELOCITY_MULTIPLIER;
455
+ const normalizedDeltaY = normalizeWheelDeltaY(e, currentContainer);
456
+ const wheelDelta = -normalizedDeltaY;
457
+ const nextPosition = wrapPosition(positionRef.current + wheelDelta * WHEEL_IMMEDIATE_DELTA_FACTOR);
458
+ commitPosition(nextPosition);
459
+ velocityRef.current += wheelDelta * WHEEL_VELOCITY_MULTIPLIER;
387
460
  velocityRef.current = Math.max(-MAX_VELOCITY, Math.min(MAX_VELOCITY, velocityRef.current));
388
461
  pauseAutoScroll();
389
462
  scheduleAutoScrollResume();
390
- }, [pauseAutoScroll, scheduleAutoScrollResume, wheelEnabled]);
463
+ }, [commitPosition, pauseAutoScroll, scheduleAutoScrollResume, wheelEnabled, wrapPosition]);
391
464
 
392
465
  // Event listener registration
393
466
  useEffect(() => {
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Behavioral and regression tests for ReactSway.
3
3
  */
4
- import { cleanup, fireEvent, render, screen } from '@testing-library/react';
4
+ import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
5
5
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
6
6
 
7
7
  import { ReactSway } from '../index';
@@ -276,6 +276,25 @@ describe('ReactSway', () => {
276
276
  });
277
277
 
278
278
  describe('wheel events', () => {
279
+ const expectMinimumScroll = async (onScroll: ReturnType<typeof vi.fn>, minimumDistance: number) => {
280
+ await waitFor(() => {
281
+ const positions = onScroll.mock.calls.map(([position]) => position as number);
282
+ expect(Math.min(...positions)).toBeLessThanOrEqual(-minimumDistance);
283
+ });
284
+ };
285
+
286
+ const renderWheelHarness = () => {
287
+ const onScroll = vi.fn();
288
+ const { container } = render(
289
+ <ReactSway autoScroll={false} friction={1} onScroll={onScroll}>
290
+ <div>Content</div>
291
+ </ReactSway>
292
+ );
293
+ const swayContainer = container.querySelector('.react-sway-container') as HTMLElement;
294
+
295
+ return { onScroll, swayContainer };
296
+ };
297
+
279
298
  it('applies wheel delta to velocity (fires onPause)', () => {
280
299
  const onPause = vi.fn();
281
300
  const { container } = render(
@@ -289,6 +308,52 @@ describe('ReactSway', () => {
289
308
  expect(onPause).toHaveBeenCalledOnce();
290
309
  });
291
310
 
311
+ it('normalizes line-based wheel deltas before applying velocity', async () => {
312
+ const { onScroll, swayContainer } = renderWheelHarness();
313
+ fireEvent.wheel(swayContainer, { deltaMode: 1, deltaY: 3 });
314
+
315
+ await expectMinimumScroll(onScroll, 14);
316
+ });
317
+
318
+ it('normalizes page-based wheel deltas before applying velocity', async () => {
319
+ const { onScroll, swayContainer } = renderWheelHarness();
320
+ fireEvent.wheel(swayContainer, { deltaMode: 2, deltaY: 1 });
321
+
322
+ await expectMinimumScroll(onScroll, 100);
323
+ });
324
+
325
+ it('normalizes tiny pixel deltas from discrete wheel hardware', async () => {
326
+ const { onScroll, swayContainer } = renderWheelHarness();
327
+ const wheelEvent = new WheelEvent('wheel', {
328
+ bubbles: true,
329
+ cancelable: true,
330
+ deltaMode: 0,
331
+ deltaY: 1,
332
+ });
333
+ Object.defineProperty(wheelEvent, 'wheelDelta', {
334
+ value: -120,
335
+ });
336
+ swayContainer.dispatchEvent(wheelEvent);
337
+
338
+ await expectMinimumScroll(onScroll, 10);
339
+ });
340
+
341
+ it('falls back to legacy wheel deltas when pixel delta is zero', async () => {
342
+ const { onScroll, swayContainer } = renderWheelHarness();
343
+ const wheelEvent = new WheelEvent('wheel', {
344
+ bubbles: true,
345
+ cancelable: true,
346
+ deltaMode: 0,
347
+ deltaY: 0,
348
+ });
349
+ Object.defineProperty(wheelEvent, 'wheelDeltaY', {
350
+ value: -120,
351
+ });
352
+ swayContainer.dispatchEvent(wheelEvent);
353
+
354
+ await expectMinimumScroll(onScroll, 10);
355
+ });
356
+
292
357
  it('caps velocity at MAX_VELOCITY', () => {
293
358
  const onPause = vi.fn();
294
359
  const { container } = render(