@weft-js/scroll 0.0.0-stage → 0.1.4

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 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
- # Temporary Holding Version
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
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.4",
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
  }