@weft-js/scroll 0.0.0-stage → 0.1.5-nightly.20261111.3
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/LICENSE +16 -0
- package/README.md +579 -2
- package/package.json +39 -3
- package/src/autoscroll.ts +91 -0
- package/src/axis.ts +59 -0
- package/src/bar-input.ts +104 -0
- package/src/bar.ts +29 -0
- package/src/canvas.ts +111 -0
- package/src/engine.ts +374 -0
- package/src/index.ts +21 -0
- package/src/keys.ts +87 -0
- package/src/options.ts +88 -0
- package/src/physics.ts +65 -0
- package/src/render.ts +205 -0
- package/src/scroller-api.ts +75 -0
- package/src/scroller.ts +240 -0
- package/src/snap.ts +35 -0
- package/src/touch.ts +169 -0
- package/src/types.ts +178 -0
- package/src/wheel-input.ts +112 -0
- package/src/wheel.ts +32 -0
- package/src/wrap.ts +27 -0
- package/styles/scroll.css +199 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ravonus
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
|
|
6
|
+
documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
|
|
7
|
+
rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit
|
|
8
|
+
persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
9
|
+
|
|
10
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
|
|
11
|
+
Software.
|
|
12
|
+
|
|
13
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
|
|
14
|
+
WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
|
15
|
+
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
|
|
16
|
+
OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,580 @@
|
|
|
1
|
-
|
|
1
|
+
<p align="center">
|
|
2
|
+
<a href="https://github.com/Ravonus/weft">
|
|
3
|
+
<img src="https://raw.githubusercontent.com/Ravonus/weft/main/site/public/logo.svg" width="88" height="88" alt="Weft">
|
|
4
|
+
</a>
|
|
5
|
+
</p>
|
|
2
6
|
|
|
3
|
-
|
|
7
|
+
<h1 align="center">@weft-js/scroll</h1>
|
|
8
|
+
|
|
9
|
+
<p align="center">
|
|
10
|
+
<strong>Custom scrollbars, drawn in the DOM or on a canvas, that never freeze.</strong><br>
|
|
11
|
+
By default the browser scrolls and the thumb follows on its compositor, so both keep moving while the main thread is busy. Engine mode runs every input in JavaScript instead, the same in every browser. Every part of the bar is yours to style.
|
|
12
|
+
</p>
|
|
13
|
+
|
|
14
|
+
<p align="center">
|
|
15
|
+
<a href="https://www.npmjs.com/package/@weft-js/scroll"><img src="https://img.shields.io/npm/v/@weft-js/scroll?color=145c3c&label=npm" alt="npm version"></a>
|
|
16
|
+
<a href="https://github.com/Ravonus/weft/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-MIT-145c3c" alt="MIT license"></a>
|
|
17
|
+
<a href="https://github.com/Ravonus/weft/actions"><img src="https://img.shields.io/github/actions/workflow/status/Ravonus/weft/quality.yml?branch=main&label=checks" alt="checks"></a>
|
|
18
|
+
<img src="https://img.shields.io/badge/node-%3E%3D24-145c3c" alt="Node 24 or later">
|
|
19
|
+
</p>
|
|
20
|
+
|
|
21
|
+
<p align="center">
|
|
22
|
+
<a href="#quick-start">Quick start</a> ·
|
|
23
|
+
<a href="#api">API</a> ·
|
|
24
|
+
<a href="https://github.com/Ravonus/weft/tree/main/docs">Docs</a> ·
|
|
25
|
+
<a href="https://github.com/Ravonus/weft/blob/main/CHANGELOG.md">Changelog</a> ·
|
|
26
|
+
<a href="https://github.com/Ravonus/weft">GitHub</a>
|
|
27
|
+
</p>
|
|
28
|
+
|
|
29
|
+
> [!IMPORTANT]
|
|
30
|
+
> **Early release (0.1.x).** APIs can change between minor versions. Pin the version and read the
|
|
31
|
+
> [changelog](https://github.com/Ravonus/weft/blob/main/CHANGELOG.md) before you upgrade.
|
|
32
|
+
|
|
33
|
+
Native scrollbars look different on every system, can't be given a fixed-size thumb, and can't be drawn the way a
|
|
34
|
+
design asks. Libraries that replace them usually move the content with a transform, and lose what the browser did
|
|
35
|
+
for free: focus scrolling into view, find-in-page, anchors, sticky headers, screen readers, and every library that
|
|
36
|
+
listens for `scroll`.
|
|
37
|
+
|
|
38
|
+
`@weft-js/scroll` keeps the browser's scroll offsets: the viewport is always a real scroll container, and the bars
|
|
39
|
+
are plain elements or a canvas that you style or paint. It has two modes:
|
|
40
|
+
|
|
41
|
+
- **Native** (the default): the browser scrolls the viewport, with its own bar hidden. Wheel, trackpad and touch
|
|
42
|
+
run on the compositor, and a CSS scroll timeline moves the DOM thumb there too, so a long task on the main
|
|
43
|
+
thread stalls neither.
|
|
44
|
+
- **Engine**: the viewport is `overflow: hidden` and every input runs in JavaScript, with this package's easing,
|
|
45
|
+
momentum, rubber band and nested hand-off, the same in Chrome, Firefox and Safari. It runs on the main thread.
|
|
46
|
+
|
|
47
|
+
Nothing in it imports a framework. Warp's [`ScrollView`](https://github.com/Ravonus/weft/blob/main/packages/warp/src/ScrollView.tsx)
|
|
48
|
+
is the Weft component over it.
|
|
49
|
+
|
|
50
|
+
## Highlights
|
|
51
|
+
|
|
52
|
+
- **Never frozen by default.** Native mode scrolls on the compositor and moves the thumb with a CSS scroll
|
|
53
|
+
timeline, so neither waits for the main thread. Where scroll timelines aren't supported yet the thumb follows
|
|
54
|
+
`scroll` events.
|
|
55
|
+
- **Native feel in engine mode too.** Notched wheels animate each step, trackpad pixels apply as they come (the OS's momentum
|
|
56
|
+
included), touch drags coast with inertia and rubber-band past the edges, and `deltaMode` lines and pages are
|
|
57
|
+
converted as browsers do.
|
|
58
|
+
- **The keys browsers use.** Arrows, Page Up and Page Down, Home and End, Space and Shift+Space, with the same step
|
|
59
|
+
sizes. A key the area can't act on reaches the page.
|
|
60
|
+
- **Nothing the browser does is lost.** Focus moving into view, find-in-page, anchors, `position: sticky`,
|
|
61
|
+
`Element.scrollIntoView` and screen readers all work, because the offsets are real.
|
|
62
|
+
- **Nested areas hand off.** A gesture latches to the area it started in; one that starts at an edge goes to the
|
|
63
|
+
area outside, or to the page, as native scrolling chains.
|
|
64
|
+
- **Any bar you like.** A fixed-size thumb, min and max sizes, colours, radius, width, inset, hover and active
|
|
65
|
+
states, auto-hide timing, overlay or gutter. Or paint the bar on a canvas with your own `draw`.
|
|
66
|
+
- **Behaviour you can tune.** Snap points, smooth-scroll duration and easing in both modes; friction, momentum,
|
|
67
|
+
wheel speed and rubber band in engine mode.
|
|
68
|
+
- **Long lists.** The viewport fires `scroll` in the same frame it moves, so a virtualizer draws with it. A test
|
|
69
|
+
scrolls 100,000 rows on Warp's virtualizer.
|
|
70
|
+
- **Right to left, reduced motion, accessibility.** `scrollLeft` runs negative in RTL as the DOM's does; reduced
|
|
71
|
+
motion makes animated scrolls instant; the bars are `scrollbar`s with values, and axe finds nothing to fix.
|
|
72
|
+
|
|
73
|
+
## Contents
|
|
74
|
+
|
|
75
|
+
- [When to use it](#when-to-use-it)
|
|
76
|
+
- [Install](#install)
|
|
77
|
+
- [Quick start](#quick-start)
|
|
78
|
+
- [Guide](#guide)
|
|
79
|
+
- [How it works](#how-it-works)
|
|
80
|
+
- [Native or engine mode](#native-or-engine-mode)
|
|
81
|
+
- [Markup](#markup)
|
|
82
|
+
- [In Weft: Warp's ScrollView](#in-weft-warps-scrollview)
|
|
83
|
+
- [In another framework](#in-another-framework)
|
|
84
|
+
- [A fixed-size thumb](#a-fixed-size-thumb)
|
|
85
|
+
- [Styling the bars](#styling-the-bars)
|
|
86
|
+
- [Bars on a canvas](#bars-on-a-canvas)
|
|
87
|
+
- [Feel: easing, friction, momentum](#feel-easing-friction-momentum)
|
|
88
|
+
- [Snap points](#snap-points)
|
|
89
|
+
- [Nested areas](#nested-areas)
|
|
90
|
+
- [Long lists](#long-lists)
|
|
91
|
+
- [Keys, focus and accessibility](#keys-focus-and-accessibility)
|
|
92
|
+
- [API](#api)
|
|
93
|
+
- [Performance](#performance)
|
|
94
|
+
- [Troubleshooting](#troubleshooting)
|
|
95
|
+
- [Related packages](#related-packages)
|
|
96
|
+
- [Contributing](#contributing)
|
|
97
|
+
- [License](#license)
|
|
98
|
+
|
|
99
|
+
## When to use it
|
|
100
|
+
|
|
101
|
+
| You want | Use |
|
|
102
|
+
|---|---|
|
|
103
|
+
| A panel, list or log whose bar looks the same everywhere, or is drawn your way | This package, or Warp's `ScrollView` in a Weft app |
|
|
104
|
+
| The page's own scrollbar restyled below a sticky header | Warp's `ScrollRail`: the document keeps native scrolling |
|
|
105
|
+
| A thin native bar, with no change to how scrolling works | CSS: `scrollbar-width` and `scrollbar-color` |
|
|
106
|
+
| Custom bars that keep up while the main thread is busy | This package in native mode, the default (see [Performance](#performance)) |
|
|
107
|
+
| Your own wheel and touch physics, the same in every browser | This package in engine mode |
|
|
108
|
+
|
|
109
|
+
## Install
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
npm install @weft-js/scroll
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
```bash
|
|
116
|
+
pnpm add @weft-js/scroll
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
```bash
|
|
120
|
+
bun add @weft-js/scroll
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
> [!NOTE]
|
|
124
|
+
> The package exports TypeScript source (`src/index.ts`), so your bundler must compile TypeScript from
|
|
125
|
+
> `node_modules`. It has no dependencies. Node 24 or later is required to install it.
|
|
126
|
+
|
|
127
|
+
## Quick start
|
|
128
|
+
|
|
129
|
+
With no framework: `wrap` turns an element's children into a scroll area in place.
|
|
130
|
+
|
|
131
|
+
```ts
|
|
132
|
+
import { wrap } from '@weft-js/scroll'
|
|
133
|
+
import '@weft-js/scroll/styles.css'
|
|
134
|
+
|
|
135
|
+
const log = document.querySelector<HTMLElement>('#log')!
|
|
136
|
+
const area = wrap(log, {
|
|
137
|
+
label: 'Build log',
|
|
138
|
+
thumb: { size: 32 },
|
|
139
|
+
bar: { show: 'always', color: 'rgb(20 92 60)' },
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
area.scrollTo({ top: 0, behavior: 'smooth' })
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
```html
|
|
146
|
+
<div id="log" style="height: 320px">
|
|
147
|
+
<p>Build 1 passed</p>
|
|
148
|
+
<p>Build 2 passed</p>
|
|
149
|
+
</div>
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
Give the area a height, as you would any scroller.
|
|
153
|
+
|
|
154
|
+
## Guide
|
|
155
|
+
|
|
156
|
+
### How it works
|
|
157
|
+
|
|
158
|
+
The root is `position: relative; overflow: hidden`. Inside it the viewport fills the root and is a scroll container,
|
|
159
|
+
so the browser still scrolls it for focus, find, anchors and `scrollIntoView`, and every `scroll` listener hears it.
|
|
160
|
+
|
|
161
|
+
- **Native mode:** the viewport stays `overflow: auto` with `scrollbar-width: none`. The browser scrolls it, and
|
|
162
|
+
the engine follows its `scroll` events to draw the bars, run `onScroll` and settle on snap points. For DOM bars,
|
|
163
|
+
the viewport also names a scroll timeline, and each thumb's `transform` is an animation on it, so the browser
|
|
164
|
+
moves the thumb in the same compositor frame as the content. The engine attaches no wheel or touch listener here:
|
|
165
|
+
a blocking one would send scrolling back through the main thread.
|
|
166
|
+
- **Engine mode:** the viewport is `overflow: hidden`, so the browser handles no wheel or touch input. The engine
|
|
167
|
+
listens for it, animates a position, and writes `scrollTop` and `scrollLeft` once per frame.
|
|
168
|
+
|
|
169
|
+
In both modes the keys, the bars' drag and track presses, and the API run here. When something else moves the
|
|
170
|
+
viewport, the engine adopts the new position and the bar follows.
|
|
171
|
+
|
|
172
|
+
Before the script runs, the stylesheet leaves the viewport on `overflow: auto` with the browser's own bar, so
|
|
173
|
+
server-rendered HTML scrolls natively until the script takes over.
|
|
174
|
+
|
|
175
|
+
### Native or engine mode
|
|
176
|
+
|
|
177
|
+
| | Native (default) | Engine |
|
|
178
|
+
|---|---|---|
|
|
179
|
+
| Wheel, trackpad, touch | The browser's, on the compositor | This package's, on the main thread |
|
|
180
|
+
| During a long task | Content and DOM thumb keep moving | Both wait for it to end |
|
|
181
|
+
| Friction, momentum, wheel speed, rubber band | The browser's | The options here |
|
|
182
|
+
| Nested areas | The browser's scroll chaining | Latching and hand-off here |
|
|
183
|
+
| Keys, bar drag, track press, snap, API | Here | Here |
|
|
184
|
+
| Canvas bars | Redrawn on each `scroll` event | Redrawn each frame |
|
|
185
|
+
|
|
186
|
+
```ts
|
|
187
|
+
scroller(el) // native
|
|
188
|
+
scroller(el, { mode: 'engine', friction: 0.995 })
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
Native mode is the one to start with. Engine mode is for physics that must match across browsers, or a host where
|
|
192
|
+
the browser's own scrolling isn't wanted. `update({ mode })` switches at any time.
|
|
193
|
+
|
|
194
|
+
### Markup
|
|
195
|
+
|
|
196
|
+
`scroller(root, options)` expects this, and makes any bar that is missing:
|
|
197
|
+
|
|
198
|
+
```html
|
|
199
|
+
<div id="area" style="height: 320px">
|
|
200
|
+
<div data-part="viewport">
|
|
201
|
+
<div data-part="content">…</div>
|
|
202
|
+
</div>
|
|
203
|
+
<div data-part="scrollbar" data-orientation="vertical">
|
|
204
|
+
<div data-part="thumb"></div>
|
|
205
|
+
</div>
|
|
206
|
+
<div data-part="scrollbar" data-orientation="horizontal">
|
|
207
|
+
<div data-part="thumb"></div>
|
|
208
|
+
</div>
|
|
209
|
+
</div>
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
```ts
|
|
213
|
+
import { scroller } from '@weft-js/scroll'
|
|
214
|
+
import '@weft-js/scroll/styles.css'
|
|
215
|
+
|
|
216
|
+
const area = scroller(document.querySelector('#area')!, {
|
|
217
|
+
axes: 'y',
|
|
218
|
+
})
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
The content element is what moves when the area rubber-bands, and what a `ResizeObserver` watches for size changes.
|
|
222
|
+
Swap it and the engine watches the new one.
|
|
223
|
+
|
|
224
|
+
### In Weft: Warp's ScrollView
|
|
225
|
+
|
|
226
|
+
```tsx
|
|
227
|
+
import { ScrollView } from '@weft-js/warp'
|
|
228
|
+
|
|
229
|
+
export function Log({ lines }: { lines: string[] }) {
|
|
230
|
+
return (
|
|
231
|
+
<ScrollView
|
|
232
|
+
label="Build log"
|
|
233
|
+
axes="y"
|
|
234
|
+
thumb={{ size: 32 }}
|
|
235
|
+
style="height:320px"
|
|
236
|
+
>
|
|
237
|
+
{lines.map((l) => (
|
|
238
|
+
<p>{l}</p>
|
|
239
|
+
))}
|
|
240
|
+
</ScrollView>
|
|
241
|
+
)
|
|
242
|
+
}
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
Every option is a prop. `controller` receives the scroller, for `scrollTo` and the rest. The bars take Warp's ink,
|
|
246
|
+
focus ring and timing. Server HTML scrolls natively until the island runs.
|
|
247
|
+
|
|
248
|
+
### In another framework
|
|
249
|
+
|
|
250
|
+
The scroller is an action: make it on mount, `update` it when options change, `destroy` it on unmount. In React:
|
|
251
|
+
|
|
252
|
+
```tsx
|
|
253
|
+
import { useEffect, useRef } from 'react'
|
|
254
|
+
import { scroller, type ScrollOptions } from '@weft-js/scroll'
|
|
255
|
+
|
|
256
|
+
export function Scroll(props: {
|
|
257
|
+
options: ScrollOptions
|
|
258
|
+
children: React.ReactNode
|
|
259
|
+
}) {
|
|
260
|
+
const root = useRef<HTMLDivElement>(null)
|
|
261
|
+
useEffect(() => {
|
|
262
|
+
const s = scroller(root.current!, props.options)
|
|
263
|
+
return () => s.destroy()
|
|
264
|
+
}, [])
|
|
265
|
+
return (
|
|
266
|
+
<div ref={root} style={{ height: 320 }}>
|
|
267
|
+
<div data-part="viewport">
|
|
268
|
+
<div data-part="content">{props.children}</div>
|
|
269
|
+
</div>
|
|
270
|
+
</div>
|
|
271
|
+
)
|
|
272
|
+
}
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
### A fixed-size thumb
|
|
276
|
+
|
|
277
|
+
A native thumb grows and shrinks with the content. `thumb.size` fixes its length; `min` and `max` bound a
|
|
278
|
+
proportional one instead.
|
|
279
|
+
|
|
280
|
+
```ts
|
|
281
|
+
scroller(el, { thumb: { size: 28 } })
|
|
282
|
+
scroller(el, { thumb: { min: 40, max: 120 } })
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
### Styling the bars
|
|
286
|
+
|
|
287
|
+
Each `bar` option sets a custom property on the root, and the stylesheet reads only those. So CSS can set them too,
|
|
288
|
+
on any element above the area, in any layer: the stylesheet declares none of them itself.
|
|
289
|
+
|
|
290
|
+
| Option | Custom property | Default |
|
|
291
|
+
|---|---|---|
|
|
292
|
+
| `width` | `--scroll-width` | `6` px |
|
|
293
|
+
| `hoverWidth` | `--scroll-hover-width` | `10` px |
|
|
294
|
+
| `inset` | `--scroll-inset` | `2` px |
|
|
295
|
+
| `radius` | `--scroll-radius` | half the width |
|
|
296
|
+
| `color` | `--scroll-color` | 32% of the text colour |
|
|
297
|
+
| `hoverColor` | `--scroll-hover-color` | 48% of the text colour |
|
|
298
|
+
| `activeColor` | `--scroll-active-color` | 60% of the text colour |
|
|
299
|
+
| `track` | `--scroll-track` | `transparent` |
|
|
300
|
+
| `hoverTrack` | `--scroll-hover-track` | 8% of the text colour |
|
|
301
|
+
| `fade` | `--scroll-fade` | `200` ms |
|
|
302
|
+
|
|
303
|
+
The rest of `bar` is behaviour:
|
|
304
|
+
|
|
305
|
+
| Option | Values | Default |
|
|
306
|
+
|---|---|---|
|
|
307
|
+
| `show` | `scroll` (while moving or hovered), `hover` (while the pointer is over the area), `always`, `never` | `scroll` |
|
|
308
|
+
| `hideDelay` | ms the bar stays after scrolling stops | `900` |
|
|
309
|
+
| `layout` | `overlay` over the content, or `gutter` beside it | `overlay` |
|
|
310
|
+
| `trackClick` | `page` (held, it repeats) or `jump`; Alt flips it | `page` |
|
|
311
|
+
|
|
312
|
+
The elements carry state for CSS: `data-visible`, `data-hover`, `data-active` and `data-none` on each bar;
|
|
313
|
+
`data-scrolling`, `data-overflow-x` and `data-overflow-y` on the root.
|
|
314
|
+
|
|
315
|
+
```css
|
|
316
|
+
#area {
|
|
317
|
+
--scroll-color: rgb(20 92 60 / 0.5);
|
|
318
|
+
--scroll-hover-color: rgb(20 92 60);
|
|
319
|
+
--scroll-radius: 2px;
|
|
320
|
+
}
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
### Bars on a canvas
|
|
324
|
+
|
|
325
|
+
`canvas()` from `@weft-js/scroll/canvas` paints each bar into its own canvas, at the device's pixel ratio. Colours
|
|
326
|
+
resolve through the same custom properties, so `color-mix()` and `currentColor` work. Pass `draw` to paint it
|
|
327
|
+
yourself; `paint` is the default look, to draw over or ignore.
|
|
328
|
+
|
|
329
|
+
```ts
|
|
330
|
+
import { scroller } from '@weft-js/scroll'
|
|
331
|
+
import { canvas } from '@weft-js/scroll/canvas'
|
|
332
|
+
|
|
333
|
+
scroller(el, {
|
|
334
|
+
renderer: canvas({
|
|
335
|
+
draw(ctx, frame, paint) {
|
|
336
|
+
paint(ctx, frame)
|
|
337
|
+
const { x, y, width, height } = frame.thumb
|
|
338
|
+
ctx.fillStyle = 'white'
|
|
339
|
+
ctx.fillRect(x, y + height / 2 - 1, width, 2)
|
|
340
|
+
},
|
|
341
|
+
}),
|
|
342
|
+
})
|
|
343
|
+
```
|
|
344
|
+
|
|
345
|
+
`frame` holds the track's size, the thumb's box, `grow` (0 at rest to 1 hovered, eased), `hover`, `active`,
|
|
346
|
+
`progress`, `rtl` and the resolved `bar` options. A frame is drawn only when one of them changed. Pointer handling
|
|
347
|
+
doesn't depend on the renderer, so a painted bar drags and pages like a DOM one. A renderer of your own implements
|
|
348
|
+
`Renderer`: `mount`, `draw` and `unmount`.
|
|
349
|
+
|
|
350
|
+
### Feel: easing, friction, momentum
|
|
351
|
+
|
|
352
|
+
`duration` and `easing` shape smooth `scrollTo`, keys, track presses and snapping in both modes. The rest apply in
|
|
353
|
+
engine mode; in native mode the browser's own wheel and touch handling decides them.
|
|
354
|
+
|
|
355
|
+
| Option | What it does | Default |
|
|
356
|
+
|---|---|---|
|
|
357
|
+
| `duration` | ms for a wheel notch, a key and a smooth `scrollTo` | `220` |
|
|
358
|
+
| `easing` | `(t) => progress`; `easings` has `linear`, `easeOut`, `easeOutQuint`, `easeInOut`, `expo` | `easeOut` |
|
|
359
|
+
| `friction` | share of a fling's velocity kept per ms | `0.998` |
|
|
360
|
+
| `momentum` | coast after a touch fling | `true` |
|
|
361
|
+
| `overscroll` | `bounce` past the edges on touch, or `none` | `bounce` |
|
|
362
|
+
| `wheelSpeed` | multiplies wheel distance | `1` |
|
|
363
|
+
| `lineHeight` | px for a wheel line and an arrow key | `40` |
|
|
364
|
+
| `grab` | drag the content with a mouse, as a hand tool | `false` |
|
|
365
|
+
| `touch` | `js` runs touch here; `native` leaves it to the browser | `js` |
|
|
366
|
+
|
|
367
|
+
```ts
|
|
368
|
+
import { easings, scroller } from '@weft-js/scroll'
|
|
369
|
+
|
|
370
|
+
scroller(el, {
|
|
371
|
+
duration: 400,
|
|
372
|
+
easing: easings.easeOutQuint,
|
|
373
|
+
friction: 0.995,
|
|
374
|
+
})
|
|
375
|
+
```
|
|
376
|
+
|
|
377
|
+
### Snap points
|
|
378
|
+
|
|
379
|
+
Like CSS scroll snapping. `mandatory` always rests on a point; `proximity` only when within `proximity` px (120 by
|
|
380
|
+
default). In native mode the area glides to a point once the browser's scroll ends. In engine mode a fling glides
|
|
381
|
+
to the point nearest where it would have stopped, and a wheel notch or an arrow key moves one point under
|
|
382
|
+
`mandatory`.
|
|
383
|
+
|
|
384
|
+
```ts
|
|
385
|
+
scroller(el, {
|
|
386
|
+
axes: 'x',
|
|
387
|
+
snap: {
|
|
388
|
+
selector: '[data-slide]',
|
|
389
|
+
align: 'start',
|
|
390
|
+
type: 'mandatory',
|
|
391
|
+
axis: 'x',
|
|
392
|
+
},
|
|
393
|
+
})
|
|
394
|
+
```
|
|
395
|
+
|
|
396
|
+
`points` takes positions in px, or a function returning them, read again whenever the area resizes.
|
|
397
|
+
|
|
398
|
+
### Nested areas
|
|
399
|
+
|
|
400
|
+
In native mode the browser chains nested areas as it does any scrollers, and `chain: 'contain'` sets
|
|
401
|
+
`overscroll-behavior: contain`. In engine mode an area inside another scrolls first. A gesture latches to the area it started in for as long as it continues, as
|
|
402
|
+
browsers do: one that reaches the inner edge midway stops there, and the next gesture at that edge moves the outer
|
|
403
|
+
area, or the page. A natively scrolling element inside an area keeps its own scrolling. `chain: 'contain'` keeps
|
|
404
|
+
every gesture in the area, like `overscroll-behavior: contain`.
|
|
405
|
+
|
|
406
|
+
### Long lists
|
|
407
|
+
|
|
408
|
+
In native mode the browser fires `scroll` in the frame it draws. In engine mode the engine fires one on the
|
|
409
|
+
viewport in the frame it writes a position (`syncEvents`, on by default). Either way a virtualizer listening for
|
|
410
|
+
`scroll` places its rows before that frame paints. Hand the viewport to
|
|
411
|
+
the virtualizer as its scroller:
|
|
412
|
+
|
|
413
|
+
```ts
|
|
414
|
+
import { scroller } from '@weft-js/scroll'
|
|
415
|
+
import { virtualizer } from '@weft-js/warp-core'
|
|
416
|
+
|
|
417
|
+
const area = scroller(root)
|
|
418
|
+
const list = virtualizer(area.viewport, {
|
|
419
|
+
keys,
|
|
420
|
+
spacer,
|
|
421
|
+
estimate: 32,
|
|
422
|
+
onRange: render,
|
|
423
|
+
})
|
|
424
|
+
```
|
|
425
|
+
|
|
426
|
+
### Keys, focus and accessibility
|
|
427
|
+
|
|
428
|
+
- **Keys:** arrows scroll a line, Page Up and Page Down most of a screen, Space and Shift+Space the same, Home and
|
|
429
|
+
End to the ends. Keys typed into fields, and arrows inside widgets that use them (listbox, grid, slider…), are
|
|
430
|
+
left alone.
|
|
431
|
+
- **Focus:** the viewport is a tab stop only when nothing inside it can take focus, as browsers treat scrollers. A
|
|
432
|
+
click in it focuses it, so the keys work after a click.
|
|
433
|
+
- **Screen readers:** the content stays in the DOM and the accessibility tree. With `label`, the viewport is a
|
|
434
|
+
named `region`. Each bar is a `scrollbar` with `aria-valuenow` (0 to 100), `aria-orientation` and
|
|
435
|
+
`aria-controls`.
|
|
436
|
+
- **Reduced motion:** with `motion: 'auto'` (the default) and `prefers-reduced-motion: reduce`, animated scrolls
|
|
437
|
+
are instant, bars stop fading and touch doesn't rubber-band.
|
|
438
|
+
- **Right to left:** `x` and `scrollLeft` run from 0 to negative, as the DOM's do. The vertical bar sits at the
|
|
439
|
+
inline end and the horizontal thumb starts from the right.
|
|
440
|
+
- **Selection:** dragging a text selection past the edge scrolls toward it, faster further out, and the selection
|
|
441
|
+
follows. Dragging a file or element over an edge does the same.
|
|
442
|
+
|
|
443
|
+
## API
|
|
444
|
+
|
|
445
|
+
### `scroller(root, options?)`
|
|
446
|
+
|
|
447
|
+
Starts the engine on `root` and returns a `Scroller`. Calling it again on the same root replaces the first.
|
|
448
|
+
|
|
449
|
+
### `wrap(el, options?)`
|
|
450
|
+
|
|
451
|
+
Moves `el`'s children into a viewport and content wrapper, then calls `scroller(el)`. `destroy` puts them back.
|
|
452
|
+
|
|
453
|
+
### Options
|
|
454
|
+
|
|
455
|
+
| Option | Type | Default |
|
|
456
|
+
|---|---|---|
|
|
457
|
+
| `mode` | `'native' \| 'engine'` ([Native or engine mode](#native-or-engine-mode)) | `'native'` |
|
|
458
|
+
| `axes` | `'x' \| 'y' \| 'both'` | `'both'` |
|
|
459
|
+
| `thumb` | `{ size?, min?, max? }` | proportional, `min` 24 |
|
|
460
|
+
| `bar` | `BarOptions` ([Styling the bars](#styling-the-bars)) | see above |
|
|
461
|
+
| `renderer` | `Renderer` | DOM bars |
|
|
462
|
+
| `duration`, `easing`, `friction`, `momentum` | see [Feel](#feel-easing-friction-momentum) | |
|
|
463
|
+
| `wheelSpeed`, `lineHeight`, `overscroll`, `grab`, `touch` | see [Feel](#feel-easing-friction-momentum) | |
|
|
464
|
+
| `chain` | `'auto' \| 'contain'` | `'auto'` |
|
|
465
|
+
| `snap` | `{ points?, selector?, align?, type?, proximity?, axis? }` | none |
|
|
466
|
+
| `keyboard` | `boolean` | `true` |
|
|
467
|
+
| `autoscroll` | `boolean`: selection and drag autoscroll | `true` |
|
|
468
|
+
| `motion` | `'auto' \| 'reduce' \| 'full'` | `'auto'` |
|
|
469
|
+
| `label` | `string`: the viewport's accessible name | none |
|
|
470
|
+
| `syncEvents` | `boolean`: in engine mode, fire `scroll` in the frame of each write | `true` |
|
|
471
|
+
| `onScroll` | `(state: ScrollState) => void`, once per frame that moved | none |
|
|
472
|
+
|
|
473
|
+
### `Scroller`
|
|
474
|
+
|
|
475
|
+
| Member | What it does |
|
|
476
|
+
|---|---|
|
|
477
|
+
| `state()` | The current `ScrollState` |
|
|
478
|
+
| `scrollTo({ top?, left?, behavior? })` | Moves there; at once unless `behavior: 'smooth'` |
|
|
479
|
+
| `scrollBy({ top?, left?, behavior? })` | Moves by that much, from where a running animation is heading |
|
|
480
|
+
| `scrollIntoView(el, { block?, inline?, behavior?, margin? })` | Brings `el` into view in this area only |
|
|
481
|
+
| `stop()` | Stops an animation or a coast where it is |
|
|
482
|
+
| `refresh()` | Measures again, after a change no `ResizeObserver` sees |
|
|
483
|
+
| `update(options)` | Merges new options in |
|
|
484
|
+
| `destroy()` | Removes listeners, bars it made and attributes |
|
|
485
|
+
| `viewport` | The viewport element |
|
|
486
|
+
|
|
487
|
+
### `ScrollState`
|
|
488
|
+
|
|
489
|
+
| Field | What it is |
|
|
490
|
+
|---|---|
|
|
491
|
+
| `x`, `y` | `scrollLeft` and `scrollTop` |
|
|
492
|
+
| `maxX`, `maxY` | The furthest each goes (`maxX` negative in RTL) |
|
|
493
|
+
| `progressX`, `progressY` | 0 at the start to 1 at the end |
|
|
494
|
+
| `overscrollX`, `overscrollY` | Rubber-band offset past an edge, px |
|
|
495
|
+
| `velocityX`, `velocityY` | px per ms |
|
|
496
|
+
| `source` | `wheel`, `touch`, `key`, `bar`, `api`, `snap`, `select` or `native` |
|
|
497
|
+
|
|
498
|
+
### Helpers
|
|
499
|
+
|
|
500
|
+
Pure functions the engine uses, exported for your own bars and tests: `thumbLength`, `thumbOffset`, `dragRatio`,
|
|
501
|
+
`progressAt`, `snapTo`, `snapStep`, `normalizeWheel`, `pageStep`, `coastDistance`, `rubber`, `easings`.
|
|
502
|
+
|
|
503
|
+
### Entries
|
|
504
|
+
|
|
505
|
+
| Entry | What it has |
|
|
506
|
+
|---|---|
|
|
507
|
+
| `@weft-js/scroll` | `scroller`, `wrap`, `domRenderer`, the helpers and the types |
|
|
508
|
+
| `@weft-js/scroll/canvas` | `canvas`, `paintBar`, `cssColor` |
|
|
509
|
+
| `@weft-js/scroll/styles.css` | The layout and look of the root, viewport and bars |
|
|
510
|
+
|
|
511
|
+
## Performance
|
|
512
|
+
|
|
513
|
+
Two scripts in this package measure it in headless Chromium on an M4 Max. `node bench/native.mjs` times the main
|
|
514
|
+
thread (median of 5 runs) and `node bench/compositor.mjs` reads Chromium's layer tree to see what runs without it:
|
|
515
|
+
|
|
516
|
+
| | Browser alone | Native mode | Native, canvas bars | Engine mode |
|
|
517
|
+
|---|---|---|---|---|
|
|
518
|
+
| Content scrolls on the compositor | yes | yes | yes | no |
|
|
519
|
+
| Thumb moves on the compositor | (its own bar) | yes | no | no |
|
|
520
|
+
| Blocking wheel or touch listeners added | 0 | 0 | 0 | 1 |
|
|
521
|
+
| Mount 200 areas of 50 rows, with layout | 6.3 ms | 22 ms | 15 ms | 19 ms |
|
|
522
|
+
| Main-thread time per frame during a 2 s trackpad stream | 0.17 ms | 0.30 ms | 0.24 ms | 0.28 ms |
|
|
523
|
+
| Frames over 20 ms in that stream | 0 | 0 | 0 | 0 |
|
|
524
|
+
| Wheel event to `scroll` event, median | 32 ms | 32 ms | 32 ms | 32 ms |
|
|
525
|
+
| Smooth scroll of 4,000 px | 1,039 ms | 208 ms | 205 ms | 208 ms |
|
|
526
|
+
| Size, brotli | 0 | 9.2 KB | + 0.9 KB | 9.2 KB |
|
|
527
|
+
|
|
528
|
+
What that means:
|
|
529
|
+
|
|
530
|
+
- In native mode a long task on the main thread doesn't stop scrolling: the content and the DOM thumb both move
|
|
531
|
+
on the compositor, as the browser's own bar does. A canvas bar, or a browser without scroll timelines (Firefox
|
|
532
|
+
keeps them behind a flag), still scrolls the content there; only the bar waits for the main thread.
|
|
533
|
+
- Engine mode runs on the main thread, so a long task stalls it as it stalls any animation in JavaScript. That is
|
|
534
|
+
the price of running every input here; keep long work off the main thread when you use it.
|
|
535
|
+
- Input reaches the screen as fast as the browser's own, and a frame costs about a tenth of a millisecond more:
|
|
536
|
+
the bars' upkeep and `onScroll`.
|
|
537
|
+
- Mounting costs about 0.05 to 0.08 ms more per area. Most of it is the browser styling and laying out the bars,
|
|
538
|
+
and an area makes bars only for the axes it scrolls.
|
|
539
|
+
- The smooth-scroll row is a choice, not a speed: Chrome takes a second for 4,000 px, the default `duration` is
|
|
540
|
+
220 ms. Set `duration` to taste.
|
|
541
|
+
|
|
542
|
+
## Troubleshooting
|
|
543
|
+
|
|
544
|
+
**The area doesn't scroll.** The root needs a height (or a parent that gives it one), and the stylesheet must be
|
|
545
|
+
loaded: without it the viewport isn't sized to the root.
|
|
546
|
+
|
|
547
|
+
**Nothing happens on touch screens in engine mode.** Check for a `touch-action` set on an ancestor. Or pass
|
|
548
|
+
`touch: 'native'` to let the browser handle touch, or use native mode.
|
|
549
|
+
|
|
550
|
+
**The bar doesn't change colour with my theme.** Set the `--scroll-*` properties in your theme rather than passing
|
|
551
|
+
colours as options: options are written inline and win over CSS.
|
|
552
|
+
|
|
553
|
+
**A canvas bar keeps an old colour after a theme change.** Canvas colours are resolved once per options and colour
|
|
554
|
+
scheme. Call `refresh()`, or `update` with new `bar` options, when a theme changes them some other way.
|
|
555
|
+
|
|
556
|
+
**The page scrolls instead of the area.** The gesture started at the area's edge, so it went to the page, as
|
|
557
|
+
native scrolling does. `chain: 'contain'` keeps it in the area.
|
|
558
|
+
|
|
559
|
+
## Related packages
|
|
560
|
+
|
|
561
|
+
| Package | What it is |
|
|
562
|
+
|---|---|
|
|
563
|
+
| [`@weft-js/warp`](https://www.npmjs.com/package/@weft-js/warp) <sup>[source](https://github.com/Ravonus/weft/tree/main/packages/warp)</sup> | Warp's components; `ScrollView` is this package as a Weft component |
|
|
564
|
+
| [`@weft-js/warp-core`](https://www.npmjs.com/package/@weft-js/warp-core) <sup>[source](https://github.com/Ravonus/weft/tree/main/packages/warp-core)</sup> | Warp's behaviour without a framework, including the `virtualizer` for long lists |
|
|
565
|
+
| [`@weft-js/core`](https://www.npmjs.com/package/@weft-js/core) <sup>[source](https://github.com/Ravonus/weft/tree/main/packages/core)</sup> | The Weft runtime |
|
|
566
|
+
| [`@weft-js/create`](https://www.npmjs.com/package/@weft-js/create) <sup>[source](https://github.com/Ravonus/weft/tree/main/packages/create)</sup> | `npm create @weft-js` starts an app with Warp |
|
|
567
|
+
|
|
568
|
+
## Contributing
|
|
569
|
+
|
|
570
|
+
See [CONTRIBUTING.md](https://github.com/Ravonus/weft/blob/main/CONTRIBUTING.md). In `packages/scroll`:
|
|
571
|
+
|
|
572
|
+
```bash
|
|
573
|
+
pnpm test # unit tests, then Chromium and Firefox
|
|
574
|
+
WEFT_WEBKIT=1 pnpm test # WebKit too
|
|
575
|
+
pnpm run bench # against native scrolling
|
|
576
|
+
```
|
|
577
|
+
|
|
578
|
+
## License
|
|
579
|
+
|
|
580
|
+
[MIT](https://github.com/Ravonus/weft/blob/main/LICENSE)
|
package/package.json
CHANGED
|
@@ -1,6 +1,42 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@weft-js/scroll",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.5-nightly.20261111.3",
|
|
4
|
+
"description": "Scrolling in JavaScript: native-feeling wheel, touch, keys and focus, with scrollbars drawn in the DOM or on a canvas",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"canvas",
|
|
7
|
+
"scroll",
|
|
8
|
+
"scrollbar",
|
|
9
|
+
"weft"
|
|
10
|
+
],
|
|
11
|
+
"homepage": "https://github.com/Ravonus/weft/tree/main/packages/scroll#readme",
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/Ravonus/weft/issues"
|
|
14
|
+
},
|
|
15
|
+
"license": "MIT",
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "git+https://github.com/Ravonus/weft.git",
|
|
19
|
+
"directory": "packages/scroll"
|
|
20
|
+
},
|
|
21
|
+
"files": [
|
|
22
|
+
"src",
|
|
23
|
+
"styles",
|
|
24
|
+
"README.md",
|
|
25
|
+
"LICENSE"
|
|
26
|
+
],
|
|
27
|
+
"type": "module",
|
|
28
|
+
"sideEffects": [
|
|
29
|
+
"*.css"
|
|
30
|
+
],
|
|
31
|
+
"exports": {
|
|
32
|
+
".": "./src/index.ts",
|
|
33
|
+
"./canvas": "./src/canvas.ts",
|
|
34
|
+
"./styles.css": "./styles/scroll.css"
|
|
35
|
+
},
|
|
36
|
+
"publishConfig": {
|
|
37
|
+
"access": "public"
|
|
38
|
+
},
|
|
39
|
+
"engines": {
|
|
40
|
+
"node": ">=24"
|
|
41
|
+
}
|
|
6
42
|
}
|