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.
Files changed (42) hide show
  1. package/README.md +390 -95
  2. package/dist/chunk-IFT3KMZJ.js +1203 -0
  3. package/dist/core.d.ts +123 -0
  4. package/dist/core.js +38 -0
  5. package/dist/index.d.ts +51 -0
  6. package/dist/index.js +348 -0
  7. package/package.json +38 -24
  8. package/dist/cjs/hooks/use-iso-morphic-effect.d.ts +0 -2
  9. package/dist/cjs/hooks/use-iso-morphic-effect.js +0 -6
  10. package/dist/cjs/hooks/use-scroll.d.ts +0 -24
  11. package/dist/cjs/hooks/use-scroll.js +0 -80
  12. package/dist/cjs/hooks/use-window-scroll.d.ts +0 -13
  13. package/dist/cjs/hooks/use-window-scroll.js +0 -62
  14. package/dist/cjs/index.d.ts +0 -3
  15. package/dist/cjs/index.js +0 -19
  16. package/dist/cjs/scroller.d.ts +0 -10
  17. package/dist/cjs/scroller.js +0 -94
  18. package/dist/cjs/types.d.ts +0 -17
  19. package/dist/cjs/types.js +0 -2
  20. package/dist/cjs/utils.d.ts +0 -37
  21. package/dist/cjs/utils.js +0 -92
  22. package/dist/esm/hooks/use-iso-morphic-effect.d.ts +0 -2
  23. package/dist/esm/hooks/use-iso-morphic-effect.js +0 -3
  24. package/dist/esm/hooks/use-scroll.d.ts +0 -24
  25. package/dist/esm/hooks/use-scroll.js +0 -76
  26. package/dist/esm/hooks/use-window-scroll.d.ts +0 -13
  27. package/dist/esm/hooks/use-window-scroll.js +0 -58
  28. package/dist/esm/index.d.ts +0 -3
  29. package/dist/esm/index.js +0 -3
  30. package/dist/esm/scroller.d.ts +0 -10
  31. package/dist/esm/scroller.js +0 -90
  32. package/dist/esm/types.d.ts +0 -17
  33. package/dist/esm/types.js +0 -1
  34. package/dist/esm/utils.d.ts +0 -37
  35. package/dist/esm/utils.js +0 -85
  36. package/src/hooks/use-iso-morphic-effect.ts +0 -4
  37. package/src/hooks/use-scroll.ts +0 -84
  38. package/src/hooks/use-window-scroll.ts +0 -73
  39. package/src/index.ts +0 -3
  40. package/src/scroller.ts +0 -107
  41. package/src/types.ts +0 -21
  42. package/src/utils.ts +0 -103
package/README.md CHANGED
@@ -1,150 +1,445 @@
1
1
  # use-scroller
2
2
 
3
- A lightweight library that enables smooth scrolling for HTML elements using simple React hooks provided by the package. This library offers additional scrolling features and provides several callback functions for customizable scroll operations.
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
- <div align="center">
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
- ## Installation
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
- ```bash
13
- #YARN
14
- yarn add use-scroller
18
+ ## Install
15
19
 
16
- #NPM
20
+ ```sh
17
21
  npm install use-scroller
22
+ ```
18
23
 
19
- #PNPM
20
- pnpm install use-scroller
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
- ## Usage
35
+ ## React hooks
25
36
 
26
- ### Scroll state
37
+ ### `useScroll`
27
38
 
28
- ```typescript
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
- export default function ScrollStateExample() {
32
- const { ref, state } = useScroll<HTMLDivElement>()
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
- <p>Scroll State:</p>
37
- <pre>{JSON.stringify(state, null, 2)}</pre>
38
- <div ref={ref} style={{ height: '200px', overflowY: 'scroll' }}>
39
- {/* Your content */}
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
- ### Carousel
67
+ The other actions are `scrollTo({ x, y })`, `scrollToElement(elementOrRef, { align, offset })` and
68
+ `cancel()`.
47
69
 
48
- ```typescript
49
- import { useScroll } from 'use-scroller'
70
+ ```ts
71
+ const page = useScroll('window')
72
+ page.scrollToElement(sectionRef, { align: 'start', offset: 64 }) // 64px sticky header
73
+ ```
50
74
 
51
- export default function CarouselExample() {
52
- const { ref, scrollLeft, scrollRight } = useScroll<HTMLDivElement>()
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
- return (
55
- <>
56
- <button onClick={() => scrollLeft()}>Scroll Left</button>
57
- <button onClick={() => scrollRight()}>Scroll Right</button>
79
+ ### `useScrollState`
58
80
 
59
- <div ref={ref} style={{ width: '400px', overflowX: 'scroll' }}>
60
- {/* Your carousel content */}
61
- </div>
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
- ### Box
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
- ```typescript
70
- import { useScroll } from 'use-scroller'
94
+ ### `useDragScroll`
71
95
 
72
- export default function BoxExample() {
73
- const { ref, scrollLeft, scrollRight, scrollTop, scrollBottom, scrollToCenter } =
74
- useScroll<HTMLDivElement>()
96
+ Axis-locked swiping for a horizontal (or vertical) track.
75
97
 
76
- return (
77
- <>
78
- <button onClick={() => scrollToCenter()}>Scroll to Center</button>
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
- ### Scroll to target
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
- ```typescript
94
- import { useWindowScroll } from 'use-scroller'
111
+ ### `useScrollLock`
95
112
 
96
- export default function ScrollToTargetExample() {
97
- const { scrollToTarget } = useWindowScroll()
98
- const ref1 = useRef(null)
99
- const ref2 = useRef(null)
100
- const ref3 = useRef(null)
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
- <button onClick={() => scrollToTarget(ref1)}>Scroll to Target 1</button>
105
- <button onClick={() => scrollToTarget(ref2)}>Scroll to Target 2</button>
106
- <button onClick={() => scrollToTarget(ref3)}>Scroll to Target 3</button>
107
-
108
- <div>
109
- <div ref={ref1} style={{ height: '400px', backgroundColor: 'lightcoral' }}>
110
- Target 1
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
- ## API Documentation
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
- ### Available Hooks
231
+ ### `observeScroll`
127
232
 
128
- #### `useScroll`
233
+ ```ts
234
+ const stop = observeScroll(list, (state) => console.log(state.progressY), { idleDelay: 120 })
235
+ ```
129
236
 
130
- This hook is designed to handle element scroll events, enabling smooth scrolling for specified HTML elements.
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
- ##### Options
241
+ ### `restoreScroll`
133
242
 
134
- - `direction` (Type: `horizontal` or `vertical`, Default: `vertical`): Specifies the desired scroll direction. You can set it to `horizontal` or `vertical` based on your needs.
243
+ ```ts
244
+ const stop = restoreScroll(list, 'inbox', { storage: localStorage, timeout: 5000 })
245
+ ```
135
246
 
136
- - `duration` (Type: `number`, Default: `300`): Sets the animation duration for scrolling. Adjust this value to control the speed of the scroll animation.
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
- - `easingOption` (Type: `EasingOptions`, Default: `ease-in-out`): Determines the type of animation to use for scrolling. You can choose from various easing options such as `ease-in`, `ease-out`, `ease-in-out`, etc., to customize the scroll animation's behavior.
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
- #### `useWindowScroll`
278
+ ### `dragScroll`, `lockScroll`
141
279
 
142
- This hook is tailored to handle window scroll events, making it easy to manage scrolling operations within the entire window.
280
+ ```ts
281
+ const stopDrag = dragScroll(track, { axis: 'x', mouse: false })
282
+ const unlock = lockScroll({ allow: [modalList] })
283
+ ```
143
284
 
144
- ##### Options
285
+ The hooks above are thin wrappers around these.
145
286
 
146
- - `duration` (Type: `number`, Default: `300`): Specifies the animation duration for scrolling operations. Adjust this value to control the speed of the scroll animation.
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
- - `easingOption` (Type: `EasingOptions`, Default: `ease-in-out`): Defines the type of animation to use for scrolling within the window. You can choose from various easing options to customize the scroll animation's behavior.
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
- Feel free to update and expand this documentation further based on your library's features and usage patterns.
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