/
use-scroll-into-view.ts
146 lines (118 loc) · 3.69 KB
/
use-scroll-into-view.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
import { useCallback, useRef, useEffect } from 'react';
import { useReducedMotion } from '../use-reduced-motion/use-reduced-motion';
import { useWindowEvent } from '../use-window-event/use-window-event';
import { easeInOutQuad } from './utils/ease-in-out-quad';
import { getRelativePosition } from './utils/get-relative-position';
import { getScrollStart } from './utils/get-scroll-start';
import { setScrollParam } from './utils/set-scroll-param';
interface ScrollIntoViewAnimation {
/** target element alignment relatively to parent based on current axis */
alignment?: 'start' | 'end' | 'center';
}
interface ScrollIntoViewParams {
/** callback fired after scroll */
onScrollFinish?: () => void;
/** duration of scroll in milliseconds */
duration?: number;
/** axis of scroll */
axis?: 'x' | 'y';
/** custom mathematical easing function */
easing?: (t: number) => number;
/** additional distance between nearest edge and element */
offset?: number;
/** indicator if animation may be interrupted by user scrolling */
cancelable?: boolean;
/** prevents content jumping in scrolling lists with multiple targets */
isList?: boolean;
}
export function useScrollIntoView<
Target extends HTMLElement,
Parent extends HTMLElement | null = null
>({
duration = 1250,
axis = 'y',
onScrollFinish,
easing = easeInOutQuad,
offset = 0,
cancelable = true,
isList = false,
}: ScrollIntoViewParams = {}) {
const frameID = useRef(0);
const startTime = useRef(0);
const shouldStop = useRef(false);
const scrollableRef = useRef<Parent>(null);
const targetRef = useRef<Target>(null);
const reducedMotion = useReducedMotion();
const cancel = (): void => {
if (frameID.current) {
cancelAnimationFrame(frameID.current);
}
};
const scrollIntoView = useCallback(
({ alignment = 'start' }: ScrollIntoViewAnimation = {}) => {
shouldStop.current = false;
if (frameID.current) {
cancel();
}
const start = getScrollStart({ parent: scrollableRef.current, axis }) ?? 0;
const change =
getRelativePosition({
parent: scrollableRef.current,
target: targetRef.current,
axis,
alignment,
offset,
isList,
}) - (scrollableRef.current ? 0 : start);
function animateScroll() {
if (startTime.current === 0) {
startTime.current = performance.now();
}
const now = performance.now();
const elapsed = now - startTime.current;
// easing timing progress
const t = reducedMotion || duration === 0 ? 1 : elapsed / duration;
const distance = start + change * easing(t);
setScrollParam({
parent: scrollableRef.current,
axis,
distance,
});
if (!shouldStop.current && t < 1) {
frameID.current = requestAnimationFrame(animateScroll);
} else {
typeof onScrollFinish === 'function' && onScrollFinish();
startTime.current = 0;
frameID.current = 0;
cancel();
}
}
animateScroll();
},
[scrollableRef, axis, duration, easing, isList, offset, onScrollFinish, reducedMotion]
);
const handleStop = () => {
if (cancelable) {
shouldStop.current = true;
}
};
/**
* detection of one of these events stops scroll animation
* wheel - mouse wheel / touch pad
* touchmove - any touchable device
*/
useWindowEvent('wheel', handleStop, {
passive: true,
});
useWindowEvent('touchmove', handleStop, {
passive: true,
});
// cleanup requestAnimationFrame
useEffect(() => cancel, []);
return {
scrollableRef,
targetRef,
scrollIntoView,
cancel,
};
}