use-scroller 1.2.0 → 2.1.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/README.md +390 -95
- package/dist/chunk-IFT3KMZJ.js +1203 -0
- package/dist/core.d.ts +123 -0
- package/dist/core.js +38 -0
- package/dist/index.d.ts +51 -0
- package/dist/index.js +348 -0
- package/package.json +38 -24
- package/dist/cjs/hooks/use-iso-morphic-effect.d.ts +0 -2
- package/dist/cjs/hooks/use-iso-morphic-effect.js +0 -6
- package/dist/cjs/hooks/use-scroll.d.ts +0 -24
- package/dist/cjs/hooks/use-scroll.js +0 -80
- package/dist/cjs/hooks/use-window-scroll.d.ts +0 -13
- package/dist/cjs/hooks/use-window-scroll.js +0 -62
- package/dist/cjs/index.d.ts +0 -3
- package/dist/cjs/index.js +0 -19
- package/dist/cjs/scroller.d.ts +0 -10
- package/dist/cjs/scroller.js +0 -94
- package/dist/cjs/types.d.ts +0 -17
- package/dist/cjs/types.js +0 -2
- package/dist/cjs/utils.d.ts +0 -37
- package/dist/cjs/utils.js +0 -92
- package/dist/esm/hooks/use-iso-morphic-effect.d.ts +0 -2
- package/dist/esm/hooks/use-iso-morphic-effect.js +0 -3
- package/dist/esm/hooks/use-scroll.d.ts +0 -24
- package/dist/esm/hooks/use-scroll.js +0 -76
- package/dist/esm/hooks/use-window-scroll.d.ts +0 -13
- package/dist/esm/hooks/use-window-scroll.js +0 -58
- package/dist/esm/index.d.ts +0 -3
- package/dist/esm/index.js +0 -3
- package/dist/esm/scroller.d.ts +0 -10
- package/dist/esm/scroller.js +0 -90
- package/dist/esm/types.d.ts +0 -17
- package/dist/esm/types.js +0 -1
- package/dist/esm/utils.d.ts +0 -37
- package/dist/esm/utils.js +0 -85
- package/src/hooks/use-iso-morphic-effect.ts +0 -4
- package/src/hooks/use-scroll.ts +0 -84
- package/src/hooks/use-window-scroll.ts +0 -73
- package/src/index.ts +0 -3
- package/src/scroller.ts +0 -107
- package/src/types.ts +0 -21
- package/src/utils.ts +0 -103
package/README.md
CHANGED
|
@@ -1,150 +1,445 @@
|
|
|
1
1
|
# use-scroller
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Scroll utilities for the web, with React hooks on top. Native scrolling stays in charge; the
|
|
4
|
+
library steps in only where the platform falls short.
|
|
4
5
|
|
|
5
|
-
|
|
6
|
-
<img src="https://s9.gifyu.com/images/ezgif.com-gif-maker3092e8916a41884d.gif" width="400"/>
|
|
7
|
-
<img src="https://s3.gifyu.com/images/ezgif.com-gif-maker-1c4ee7c66dcd4dd01.gif" width="400"/>
|
|
8
|
-
</div>
|
|
6
|
+
## What it fixes
|
|
9
7
|
|
|
10
|
-
|
|
8
|
+
- **Swipe tracks that drift.** On a phone, a slightly diagonal swipe on a horizontal carousel can
|
|
9
|
+
move the page, the carousel, or both. `dragScroll` decides once per gesture and sticks to one axis.
|
|
10
|
+
- **Modal scroll bleed on iOS.** `overflow: hidden` on the page doesn't stop iOS Safari from
|
|
11
|
+
scrolling it behind a modal. `lockScroll` adds a touch guard designed for iOS Safari (verify on a
|
|
12
|
+
device) and keeps the modal's own list scrollable.
|
|
13
|
+
- **Animations that fight the user.** `animateScroll` stops the moment the user touches, wheels or
|
|
14
|
+
presses a key, pauses CSS scroll snapping while it runs, and respects reduced motion.
|
|
15
|
+
- Also: scroll state (edges, progress, direction, velocity), scroll spy, in-view detection, scroll
|
|
16
|
+
restoration, and sticking to the bottom of a chat or log.
|
|
11
17
|
|
|
12
|
-
|
|
13
|
-
#YARN
|
|
14
|
-
yarn add use-scroller
|
|
18
|
+
## Install
|
|
15
19
|
|
|
16
|
-
|
|
20
|
+
```sh
|
|
17
21
|
npm install use-scroller
|
|
22
|
+
```
|
|
18
23
|
|
|
19
|
-
|
|
20
|
-
|
|
24
|
+
| Import | Contains | Needs React |
|
|
25
|
+
| ------------------- | ------------------------------------- | ----------- |
|
|
26
|
+
| `use-scroller` | React hooks plus everything from core | 18 or 19 |
|
|
27
|
+
| `use-scroller/core` | Framework-free functions | No |
|
|
21
28
|
|
|
22
|
-
|
|
29
|
+
React is an optional peer dependency, so the core entry works in any framework or none. Both
|
|
30
|
+
entries are ESM-only with types, and are safe to import during server rendering.
|
|
31
|
+
|
|
32
|
+
A scroll target is an `HTMLElement` or `window` (`ScrollTarget`). Every function that sets
|
|
33
|
+
something up returns a cleanup function.
|
|
23
34
|
|
|
24
|
-
##
|
|
35
|
+
## React hooks
|
|
25
36
|
|
|
26
|
-
###
|
|
37
|
+
### `useScroll`
|
|
27
38
|
|
|
28
|
-
|
|
39
|
+
`useScroll(target, options?)` gives animated scrolling for a ref or `'window'`. Hook options are
|
|
40
|
+
defaults; each call can override them.
|
|
41
|
+
|
|
42
|
+
```tsx
|
|
43
|
+
import { useRef } from 'react'
|
|
29
44
|
import { useScroll } from 'use-scroller'
|
|
30
45
|
|
|
31
|
-
|
|
32
|
-
const
|
|
46
|
+
function Feed() {
|
|
47
|
+
const ref = useRef<HTMLDivElement>(null)
|
|
48
|
+
const { scrollToEdge, scrollBy } = useScroll(ref, { animation: { type: 'spring' } })
|
|
49
|
+
|
|
50
|
+
async function toBottom() {
|
|
51
|
+
const result = await scrollToEdge('bottom').finished // 'completed' | 'interrupted' | 'cancelled'
|
|
52
|
+
if (result === 'interrupted') showToast('Stopped where you grabbed it')
|
|
53
|
+
}
|
|
33
54
|
|
|
34
55
|
return (
|
|
35
56
|
<>
|
|
36
|
-
<
|
|
37
|
-
<
|
|
38
|
-
<div ref={ref} style={{
|
|
39
|
-
|
|
57
|
+
<button onClick={toBottom}>Latest</button>
|
|
58
|
+
<button onClick={() => scrollBy({ y: 300 })}>More</button>
|
|
59
|
+
<div ref={ref} style={{ overflow: 'auto', height: 300 }}>
|
|
60
|
+
…
|
|
40
61
|
</div>
|
|
41
62
|
</>
|
|
42
63
|
)
|
|
43
64
|
}
|
|
44
65
|
```
|
|
45
66
|
|
|
46
|
-
|
|
67
|
+
The other actions are `scrollTo({ x, y })`, `scrollToElement(elementOrRef, { align, offset })` and
|
|
68
|
+
`cancel()`.
|
|
47
69
|
|
|
48
|
-
```
|
|
49
|
-
|
|
70
|
+
```ts
|
|
71
|
+
const page = useScroll('window')
|
|
72
|
+
page.scrollToElement(sectionRef, { align: 'start', offset: 64 }) // 64px sticky header
|
|
73
|
+
```
|
|
50
74
|
|
|
51
|
-
|
|
52
|
-
|
|
75
|
+
Actions have stable identities, return a `ScrollHandle` (`{ finished, cancel() }`) and throw if the
|
|
76
|
+
ref isn't attached; `cancel()` returns nothing and does nothing then. A new animation on the same target cancels the previous one; unmounting cancels
|
|
77
|
+
a running one.
|
|
53
78
|
|
|
54
|
-
|
|
55
|
-
<>
|
|
56
|
-
<button onClick={() => scrollLeft()}>Scroll Left</button>
|
|
57
|
-
<button onClick={() => scrollRight()}>Scroll Right</button>
|
|
79
|
+
### `useScrollState`
|
|
58
80
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
81
|
+
Live scroll state for a ref or `'window'`. Pass a selector to re-render only when that value changes.
|
|
82
|
+
|
|
83
|
+
```tsx
|
|
84
|
+
const state = useScrollState(ref)
|
|
85
|
+
const atEnd = useScrollState(ref, (s) => s.atRight)
|
|
86
|
+
const progress = useScrollState('window', (s) => s.progressY)
|
|
65
87
|
```
|
|
66
88
|
|
|
67
|
-
|
|
89
|
+
`ScrollState` fields: `x`, `y`, `minX`, `maxX`, `maxY`, `progressX`, `progressY` (0–1), `atTop`,
|
|
90
|
+
`atBottom`, `atLeft`, `atRight`, `canScrollX`, `canScrollY`, `directionX`, `directionY` (−1, 0, 1),
|
|
91
|
+
`velocityX`, `velocityY` (px/s) and `isScrolling`. Edges and progress are correct in RTL. Before
|
|
92
|
+
mount and on the server every position is 0, every edge is reached and nothing can scroll.
|
|
68
93
|
|
|
69
|
-
|
|
70
|
-
import { useScroll } from 'use-scroller'
|
|
94
|
+
### `useDragScroll`
|
|
71
95
|
|
|
72
|
-
|
|
73
|
-
const { ref, scrollLeft, scrollRight, scrollTop, scrollBottom, scrollToCenter } =
|
|
74
|
-
useScroll<HTMLDivElement>()
|
|
96
|
+
Axis-locked swiping for a horizontal (or vertical) track.
|
|
75
97
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
<button onClick={() => scrollLeft()}>Scroll Left</button>
|
|
80
|
-
<button onClick={() => scrollRight()}>Scroll Right</button>
|
|
81
|
-
<button onClick={() => scrollTop()}>Scroll to Top</button>
|
|
82
|
-
<button onClick={() => scrollBottom()}>Scroll to Bottom</button>
|
|
83
|
-
<div ref={ref} style={{ width: '300px', height: '300px', overflow: 'scroll' }}>
|
|
84
|
-
{/* Your content */}
|
|
85
|
-
</div>
|
|
86
|
-
</>
|
|
87
|
-
)
|
|
88
|
-
}
|
|
98
|
+
```tsx
|
|
99
|
+
const track = useRef<HTMLUListElement>(null)
|
|
100
|
+
useDragScroll(track, { axis: 'x', mouse: true })
|
|
89
101
|
```
|
|
90
102
|
|
|
91
|
-
|
|
103
|
+
It sets `touch-action: pan-y pinch-zoom` on the track (`pan-x pinch-zoom` for `axis: 'y'`), so
|
|
104
|
+
cross-axis movement scrolls the page natively and pinch-zoom keeps working. A gesture
|
|
105
|
+
is judged after 8px: mostly sideways and the track follows the finger, otherwise it is left to the
|
|
106
|
+
browser. Release continues with momentum and lands on a CSS scroll-snap position if the track has
|
|
107
|
+
any. Like native scrolling, it never carries past a snap position whose area has
|
|
108
|
+
`scroll-snap-stop: always`. A drag doesn't fire a click on whatever is under the finger. Wheel, trackpad, keyboard and the scrollbar stay
|
|
109
|
+
native. `mouse: true` also lets a mouse drag the track.
|
|
92
110
|
|
|
93
|
-
|
|
94
|
-
import { useWindowScroll } from 'use-scroller'
|
|
111
|
+
### `useScrollLock`
|
|
95
112
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
113
|
+
Locks page scrolling while `active` is true. Elements in `allow` stay scrollable and never chain to
|
|
114
|
+
the page.
|
|
115
|
+
|
|
116
|
+
```tsx
|
|
117
|
+
const [open, setOpen] = useState(false)
|
|
118
|
+
const list = useRef<HTMLUListElement>(null)
|
|
119
|
+
useScrollLock(open, { allow: [list] })
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
Locks are reference-counted, so nested modals work. The scrollbar's width is added as body padding
|
|
123
|
+
so the layout doesn't shift, and the page's scroll position is kept.
|
|
124
|
+
|
|
125
|
+
### `useScrollSpy`
|
|
126
|
+
|
|
127
|
+
Returns the index of the section at the reading line, or −1 before the first one. Sections are refs
|
|
128
|
+
or element ids.
|
|
129
|
+
|
|
130
|
+
```tsx
|
|
131
|
+
const active = useScrollSpy(['intro', 'install', apiRef], { offset: 64 })
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
`offset` moves the reading line down from the root's top edge, usually by a sticky header's height.
|
|
135
|
+
`root` is a ref to a scroll container (default: the window). A missing id throws.
|
|
136
|
+
|
|
137
|
+
### `useInView`
|
|
138
|
+
|
|
139
|
+
```tsx
|
|
140
|
+
const ref = useRef<HTMLDivElement>(null)
|
|
141
|
+
const { inView, entry } = useInView(ref, { threshold: 0.3, once: true })
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
Also takes `root` and `rootMargin`, like `IntersectionObserver`. With `once`, `inView` stays true
|
|
145
|
+
after the first intersection.
|
|
146
|
+
|
|
147
|
+
### `useStickToBottom`
|
|
148
|
+
|
|
149
|
+
Keeps a chat, log or streaming reply pinned to its newest line.
|
|
150
|
+
|
|
151
|
+
```tsx
|
|
152
|
+
function Chat({ messages }: { messages: Message[] }) {
|
|
153
|
+
const list = useRef<HTMLOListElement>(null)
|
|
154
|
+
const { isAtBottom, scrollToBottom } = useStickToBottom(list)
|
|
101
155
|
|
|
102
156
|
return (
|
|
103
|
-
|
|
104
|
-
<
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
</div>
|
|
112
|
-
<div ref={ref2} style={{ height: '400px', backgroundColor: 'lightseagreen' }}>
|
|
113
|
-
Target 2
|
|
114
|
-
</div>
|
|
115
|
-
<div ref={ref3} style={{ height: '400px', backgroundColor: 'lightsalmon' }}>
|
|
116
|
-
Target 3
|
|
117
|
-
</div>
|
|
118
|
-
</div>
|
|
119
|
-
</>
|
|
157
|
+
<div style={{ position: 'relative' }}>
|
|
158
|
+
<ol ref={list} style={{ overflow: 'auto', height: 400 }}>
|
|
159
|
+
{messages.map((message) => (
|
|
160
|
+
<li key={message.id}>{message.text}</li>
|
|
161
|
+
))}
|
|
162
|
+
</ol>
|
|
163
|
+
{!isAtBottom && <button onClick={() => scrollToBottom()}>↓ New messages</button>}
|
|
164
|
+
</div>
|
|
120
165
|
)
|
|
121
166
|
}
|
|
122
167
|
```
|
|
123
168
|
|
|
124
|
-
|
|
169
|
+
It jumps to the bottom on mount. While at the bottom, it jumps to the new bottom whenever the
|
|
170
|
+
content grows or the element resizes: a new child, a child getting taller as a reply streams into it,
|
|
171
|
+
or text changing directly inside the element. The jump is instant so streamed text never lags
|
|
172
|
+
behind. Scrolling up more than 8px from the bottom stops the following; coming back within 8px
|
|
173
|
+
resumes it. `scrollToBottom(options?)` animates with the `animateScroll` options, follows again
|
|
174
|
+
right away, and returns a `ScrollHandle`. It throws if the ref isn't attached. The target must be
|
|
175
|
+
an element, not the window.
|
|
176
|
+
|
|
177
|
+
### `useScrollRestoration`
|
|
178
|
+
|
|
179
|
+
Saves a scroll position under a key and puts it back on the next mount: after a reload, or when
|
|
180
|
+
the user comes back to a route.
|
|
181
|
+
|
|
182
|
+
```tsx
|
|
183
|
+
const list = useRef<HTMLUListElement>(null)
|
|
184
|
+
useScrollRestoration(list, 'inbox')
|
|
185
|
+
useScrollRestoration('window', `page:${pathname}`) // pathname from your router, not `location`, so it renders on the server
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
Options are `storage` (default `sessionStorage`) and `timeout` (default 3000 ms); `restoreScroll`
|
|
189
|
+
below has the details. A new key or element starts over with that key's saved position.
|
|
190
|
+
|
|
191
|
+
## Core functions
|
|
192
|
+
|
|
193
|
+
Everything below is exported from both entries.
|
|
194
|
+
|
|
195
|
+
### `animateScroll`
|
|
196
|
+
|
|
197
|
+
```ts
|
|
198
|
+
import { animateScroll, easings, cubicBezier } from 'use-scroller/core'
|
|
199
|
+
|
|
200
|
+
const handle = animateScroll(
|
|
201
|
+
window,
|
|
202
|
+
{ y: 800 },
|
|
203
|
+
{
|
|
204
|
+
animation: { type: 'tween', duration: 300, easing: easings.easeInOutCubic },
|
|
205
|
+
}
|
|
206
|
+
)
|
|
207
|
+
await handle.finished
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
| Option | Default | Notes |
|
|
211
|
+
| ---------------------- | ------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
|
|
212
|
+
| `animation` | `{ type: 'tween' }` | `tween` (`duration` 450, `easing` easeOutCubic), `spring` (`stiffness` 170, `damping` 26, `mass` 1, `velocity`), or `instant` |
|
|
213
|
+
| `interruptible` | `true` | wheel, touchstart, pointerdown or keydown on the target stops it with `'interrupted'` |
|
|
214
|
+
| `respectReducedMotion` | `true` | jumps instantly when `prefers-reduced-motion: reduce` matches |
|
|
215
|
+
|
|
216
|
+
A missing axis doesn't move. Targets are clamped to the scroll range every frame. `easings` has
|
|
217
|
+
`linear`, `easeInCubic`, `easeOutCubic` and `easeInOutCubic`; `cubicBezier(0.25, 0.1, 0.25, 1)` builds
|
|
218
|
+
any CSS curve.
|
|
219
|
+
|
|
220
|
+
### `scrollToEdge`, `scrollToElement`, `scrollBy`, `cancelScroll`
|
|
221
|
+
|
|
222
|
+
```ts
|
|
223
|
+
scrollToEdge(list, 'bottom') // 'top' | 'bottom' | 'left' | 'right'; left and right respect RTL
|
|
224
|
+
scrollToElement(window, heading, { align: 'center', offset: 64 }) // 'start' | 'center' | 'end' | 'nearest'
|
|
225
|
+
scrollBy(list, { y: 200 }) // repeated calls add up while an animation is running
|
|
226
|
+
cancelScroll(list)
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
All take the same options as `animateScroll` and return a `ScrollHandle`.
|
|
125
230
|
|
|
126
|
-
###
|
|
231
|
+
### `observeScroll`
|
|
127
232
|
|
|
128
|
-
|
|
233
|
+
```ts
|
|
234
|
+
const stop = observeScroll(list, (state) => console.log(state.progressY), { idleDelay: 120 })
|
|
235
|
+
```
|
|
129
236
|
|
|
130
|
-
|
|
237
|
+
Emits a `ScrollState` once on subscribe, then at most once per frame when something changed,
|
|
238
|
+
including when the content or container resizes. `isScrolling` turns false after `idleDelay` ms
|
|
239
|
+
without scroll events.
|
|
131
240
|
|
|
132
|
-
|
|
241
|
+
### `restoreScroll`
|
|
133
242
|
|
|
134
|
-
|
|
243
|
+
```ts
|
|
244
|
+
const stop = restoreScroll(list, 'inbox', { storage: localStorage, timeout: 5000 })
|
|
245
|
+
```
|
|
135
246
|
|
|
136
|
-
-
|
|
247
|
+
- The position is stored as JSON under `use-scroller:<key>`. With the default `sessionStorage` it
|
|
248
|
+
survives reloads and back/forward navigation in the same tab, but not a new tab.
|
|
249
|
+
- A saved position the content can already reach is written instantly. If the content isn't tall
|
|
250
|
+
enough yet (data still loading), it waits for the content to grow and restores as soon as the
|
|
251
|
+
position fits.
|
|
252
|
+
- Wheel, touch, pointer down or a key press on the target (the window, for a window target) during
|
|
253
|
+
the wait cancels the restore. The user's own scrolling wins.
|
|
254
|
+
- After `timeout` ms it stops waiting and scrolls as close to the saved position as the content
|
|
255
|
+
allows. A timeout outside 0–2147483647 ms throws a `RangeError`.
|
|
256
|
+
- Saving starts only once restoring has finished or been cancelled, so the initial position of 0
|
|
257
|
+
never overwrites the saved one. From then on it saves on scroll (at most once per frame), on
|
|
258
|
+
`pagehide`, and on cleanup.
|
|
259
|
+
- For `window`, it sets `history.scrollRestoration = 'manual'` while active, so the browser's own
|
|
260
|
+
restore can't move the page after ours, and puts the previous value back on cleanup.
|
|
261
|
+
- If the storage is blocked or throws on read (privacy settings, sandboxed iframes), it does
|
|
262
|
+
nothing: no restore, no saving, no error. A value under the key that isn't `{ x, y }` with finite
|
|
263
|
+
numbers is ignored. A failed write (storage full) is skipped and the next scroll tries again.
|
|
264
|
+
|
|
265
|
+
### `stickToBottom`
|
|
266
|
+
|
|
267
|
+
```ts
|
|
268
|
+
const sticky = stickToBottom(log, (isAtBottom) => {
|
|
269
|
+
newMessagesButton.hidden = isAtBottom
|
|
270
|
+
})
|
|
271
|
+
sticky.scrollToBottom({ animation: { type: 'spring' } })
|
|
272
|
+
sticky.stop()
|
|
273
|
+
```
|
|
137
274
|
|
|
138
|
-
|
|
275
|
+
`onChange` is called once on attach, then only when the state changes. `scrollToBottom` throws after
|
|
276
|
+
`stop()`. Behavior is the same as `useStickToBottom` above.
|
|
139
277
|
|
|
140
|
-
|
|
278
|
+
### `dragScroll`, `lockScroll`
|
|
141
279
|
|
|
142
|
-
|
|
280
|
+
```ts
|
|
281
|
+
const stopDrag = dragScroll(track, { axis: 'x', mouse: false })
|
|
282
|
+
const unlock = lockScroll({ allow: [modalList] })
|
|
283
|
+
```
|
|
143
284
|
|
|
144
|
-
|
|
285
|
+
The hooks above are thin wrappers around these.
|
|
145
286
|
|
|
146
|
-
|
|
287
|
+
### `observeActiveSection`
|
|
288
|
+
|
|
289
|
+
```ts
|
|
290
|
+
const stop = observeActiveSection(sections, (index) => highlight(index), {
|
|
291
|
+
root: window,
|
|
292
|
+
offset: 64,
|
|
293
|
+
})
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
### Low-level helpers
|
|
297
|
+
|
|
298
|
+
```ts
|
|
299
|
+
readScroll(target) // { x, y, minX, maxX, maxY, viewportWidth, viewportHeight }
|
|
300
|
+
isScrollable(element, 'y')
|
|
301
|
+
findScrollParent(element, 'y') // nearest scrollable ancestor, or window
|
|
302
|
+
readSnapPositions(element, 'x') // CSS scroll-snap positions the container can rest at, ascending
|
|
303
|
+
```
|
|
147
304
|
|
|
148
|
-
|
|
305
|
+
## Recipes
|
|
306
|
+
|
|
307
|
+
### Infinite scroll
|
|
308
|
+
|
|
309
|
+
Load the next page when a sentinel after the last item comes within 800px of the viewport.
|
|
310
|
+
|
|
311
|
+
```tsx
|
|
312
|
+
function Feed() {
|
|
313
|
+
const { items, hasMore, loading, loadMore } = useFeed()
|
|
314
|
+
const sentinel = useRef<HTMLDivElement>(null)
|
|
315
|
+
const { inView } = useInView(sentinel, { rootMargin: '0px 0px 800px 0px' })
|
|
316
|
+
|
|
317
|
+
useEffect(() => {
|
|
318
|
+
if (inView && hasMore && !loading) loadMore()
|
|
319
|
+
}, [inView, hasMore, loading, loadMore])
|
|
320
|
+
|
|
321
|
+
return (
|
|
322
|
+
<>
|
|
323
|
+
{items.map((item) => (
|
|
324
|
+
<Card key={item.id} item={item} />
|
|
325
|
+
))}
|
|
326
|
+
{hasMore && <div ref={sentinel} />}
|
|
327
|
+
</>
|
|
328
|
+
)
|
|
329
|
+
}
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
`loading` is a dependency, so the effect runs again after each page. If a short page leaves the
|
|
333
|
+
sentinel in view, the next one loads without waiting for another scroll. Inside a scroll container,
|
|
334
|
+
also pass `root: listRef`.
|
|
335
|
+
|
|
336
|
+
### Hide the header while scrolling down
|
|
337
|
+
|
|
338
|
+
```tsx
|
|
339
|
+
function Header() {
|
|
340
|
+
const hidden = useScrollState('window', (s) => s.directionY === 1 && s.y > 64)
|
|
341
|
+
return <header className={hidden ? 'header header--hidden' : 'header'}>…</header>
|
|
342
|
+
}
|
|
343
|
+
```
|
|
344
|
+
|
|
345
|
+
`directionY` keeps its last value when scrolling stops, so the header stays hidden until the user
|
|
346
|
+
scrolls up. The selector returns a boolean, so the header re-renders only when it flips. For a
|
|
347
|
+
scroll container, pass its ref instead of `'window'`.
|
|
348
|
+
|
|
349
|
+
### Load older messages without a jump
|
|
350
|
+
|
|
351
|
+
No JavaScript is needed to keep the view still when older messages are inserted above it. Scroll
|
|
352
|
+
anchoring (`overflow-anchor: auto`, the default) keeps the visible messages in place. Anchoring does
|
|
353
|
+
nothing at scroll position 0, so start loading before the user reaches the top:
|
|
354
|
+
|
|
355
|
+
```tsx
|
|
356
|
+
const list = useRef<HTMLOListElement>(null)
|
|
357
|
+
const firstMessage = useRef<HTMLLIElement>(null)
|
|
358
|
+
const { inView } = useInView(firstMessage, { root: list, rootMargin: '600px 0px 0px 0px' })
|
|
359
|
+
```
|
|
149
360
|
|
|
150
|
-
|
|
361
|
+
Scroll anchoring is on by default in Chromium and Firefox. Safari went without it for a long time,
|
|
362
|
+
so check the versions you support. Where it's missing, read `scrollHeight - scrollTop` before the
|
|
363
|
+
insert and set `scrollTop` back from it in a layout effect after.
|
|
364
|
+
|
|
365
|
+
## CSS first
|
|
366
|
+
|
|
367
|
+
Several scroll problems need no JavaScript. Reach for these before the library.
|
|
368
|
+
|
|
369
|
+
| Problem | CSS | When you still need use-scroller |
|
|
370
|
+
| ----------------------------------------------------- | ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------- |
|
|
371
|
+
| A nested list scrolls the page when it hits its end | `overscroll-behavior: contain` on the list | The page must not move at all behind a modal on iOS, including when touching the backdrop: `lockScroll` |
|
|
372
|
+
| Diagonal swipes on a carousel move the page | `touch-action: pan-x pinch-zoom` on the track | You also want vertical swipes on the track to scroll the page: `dragScroll` |
|
|
373
|
+
| Carousel should stop on cards | `scroll-snap-type: x mandatory` + `scroll-snap-align: start` | Only when you also need axis locking or mouse dragging; `dragScroll` lands on the same snap points |
|
|
374
|
+
| Layout jumps when content starts or stops overflowing | `scrollbar-gutter: stable` on the scroll container | Never for this |
|
|
375
|
+
| Anchor links hide under a sticky header | `scroll-margin-top` on the targets | You need to control duration or easing, or stop when the user scrolls: `scrollToElement` with `offset` |
|
|
376
|
+
|
|
377
|
+
## Known limitations
|
|
378
|
+
|
|
379
|
+
- Scroll spy highlights the last section once the root is scrolled to the end, even if a short last
|
|
380
|
+
section never reached the reading line.
|
|
381
|
+
- Hooks that take a ref pick up its element on the commit that attaches it. An element attached in a
|
|
382
|
+
later commit that doesn't re-render the hook's component goes unnoticed until that component renders.
|
|
383
|
+
- `dragScroll` assumes `writing-mode: horizontal-tb`.
|
|
384
|
+
- Release momentum after a drag ignores `prefers-reduced-motion`, as native momentum does.
|
|
385
|
+
Programmatic scrolls (`animateScroll` and everything built on it) respect it.
|
|
386
|
+
- `stickToBottom` watches the element and its direct children. Growth that doesn't resize either
|
|
387
|
+
(for example an absolutely positioned grandchild) isn't followed.
|
|
388
|
+
- `restoreScroll` saves one position per key, not per history entry. Two history entries that
|
|
389
|
+
share a key share a position, so put the route or item id in the key.
|
|
390
|
+
- `restoreScroll` on the window and a router's own scroll restoration both move the page. Use one.
|
|
391
|
+
|
|
392
|
+
## Browser support
|
|
393
|
+
|
|
394
|
+
Automated tests run in headless Chromium. These still need verification on real iOS Safari and
|
|
395
|
+
Android devices:
|
|
396
|
+
|
|
397
|
+
- the gesture features: `dragScroll` and the iOS part of `lockScroll`;
|
|
398
|
+
- `restoreScroll` saving on `pagehide` in iOS Safari, and pages coming back from the back/forward
|
|
399
|
+
cache (the page returns as it was, so nothing should need restoring);
|
|
400
|
+
- `stickToBottom` when content grows during iOS momentum scrolling or the rubber-band bounce at the
|
|
401
|
+
bottom.
|
|
402
|
+
|
|
403
|
+
## Migrating from v1
|
|
404
|
+
|
|
405
|
+
v2 is a rewrite. Animation options are objects, scroll state moved to its own hook, every action
|
|
406
|
+
names its axis, and the package is ESM-only.
|
|
407
|
+
|
|
408
|
+
| v1 | v2 |
|
|
409
|
+
| ------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------- |
|
|
410
|
+
| `const { ref } = useScroll()` | `const ref = useRef(null)`, then `useScroll(ref)` |
|
|
411
|
+
| `useScroll({ animation: 'easing', easing: 'ease-out', duration: 300 })` | `useScroll(ref, { animation: { type: 'tween', duration: 300, easing: easings.easeOutCubic } })` |
|
|
412
|
+
| `easing: 'linear' \| 'ease-in' \| 'ease-out' \| 'ease-in-out'` | `easings.linear`, `easeInCubic`, `easeOutCubic`, `easeInOutCubic` (cubic, not quadratic), or `cubicBezier(…)` |
|
|
413
|
+
| `animation: 'momentum'`, `friction` | `animation: { type: 'spring', stiffness, damping, mass }` |
|
|
414
|
+
| `animation: 'native'`, `behavior` | Removed. Use `element.scrollTo({ behavior: 'smooth' })`, or `{ type: 'instant' }` |
|
|
415
|
+
| `direction: 'horizontal' \| 'vertical'` | Removed. Each call says which axis: `scrollTo({ x })`, `scrollTo({ y })` |
|
|
416
|
+
| `respectMotionPreference` | `respectReducedMotion` (default `true`) |
|
|
417
|
+
| `const { state } = useScroll()` | `useScrollState(ref)` |
|
|
418
|
+
| `state.left`, `state.top` | `x`, `y` |
|
|
419
|
+
| `isScrolledLeft`, `isScrolledRight`, `isScrolledTop`, `isScrolledBottom` | `atLeft`, `atRight`, `atTop`, `atBottom` |
|
|
420
|
+
| `maxScrollLeft`, `maxScrollTop` | `maxX` (and `minX` in RTL), `maxY` |
|
|
421
|
+
| `scrollPercentageX`, `scrollPercentageY` (0–100) | `progressX`, `progressY` (0–1) |
|
|
422
|
+
| `isScrollable` | `canScrollX`, `canScrollY` |
|
|
423
|
+
| `scrollLeft()`, `scrollRight()`, `scrollTop()`, `scrollBottom()` | `scrollToEdge('left' \| 'right' \| 'top' \| 'bottom')` |
|
|
424
|
+
| `scrollLeft(n)`, `scrollTop(n)` (scroll to position `n`) | `scrollTo({ x: n })`, `scrollTo({ y: n })` |
|
|
425
|
+
| `scrollTo(n)` | `scrollTo({ x: n })` or `scrollTo({ y: n })` |
|
|
426
|
+
| `scrollCenter()` | `scrollTo({ y: readScroll(ref.current).maxY / 2 })` |
|
|
427
|
+
| `cancelScroll()` | `cancel()` |
|
|
428
|
+
| `useWindowScroll()` | `useScroll('window')` |
|
|
429
|
+
| `useWindowScroll().state` | `useScrollState('window')` |
|
|
430
|
+
| `useWindowScroll().scrollToTarget(el)` | `useScroll('window').scrollToElement(el, { offset })` |
|
|
431
|
+
| `require('use-scroller')` | `import` (ESM-only) |
|
|
432
|
+
| Actions do nothing while the ref is unattached | Actions throw (`cancel()` does nothing) |
|
|
433
|
+
| `createEasingAnimation`, `createMomentumAnimation` | Removed. Use `animateScroll` |
|
|
434
|
+
| Types `ScrollOptions`, `EasingOptions`, `UseScrollReturn` | `AnimateScrollOptions`, `ScrollAnimation`, `Easing`, `ScrollActions`, `ScrollState` |
|
|
435
|
+
|
|
436
|
+
## Demo site
|
|
437
|
+
|
|
438
|
+
[silverlila.github.io/use-scroller](https://silverlila.github.io/use-scroller/) walks through every
|
|
439
|
+
export with its code and a live demo. The source is in `site/`: run `yarn` at the root, then
|
|
440
|
+
`npm install && npm run dev` inside `site/`; the dev server listens on your LAN so you can open it on
|
|
441
|
+
a phone.
|
|
442
|
+
|
|
443
|
+
## License
|
|
444
|
+
|
|
445
|
+
MIT
|