RFC-014: Scroll Input Model #
Status: Opt-in mode shipped in vlist 2.7.0 (2026-09-14); 3.0 keeps native scrolling as the default and synthetic input opt-in (shape revised 2026-09-15)
Author: floor
Type: Core Architecture
Created: 2026-09-10
Amended: 2026-09-13 — prototype review findings folded into the contract and gates; 2.7 opt-in and 3.0 milestones separated
Target: vlist 2.7 (opt-in synthetic mode, additive); a separate, conditional 3.0 decision for any default change or native-path removal
Supersedes: RFC-013 (Unified Scroll Model draft; reviewed as Spatial Navigation Model in discussion #117, rejected)
Extends: RFC-012: Logical Scroll Model
Discussion: #127
RFC-012 settled where the scroll position lives. This RFC settles where scroll input comes from. It replaces RFC-013, which mandated a bounded-only model with the native viewport path removed and was rejected by the June committee (Claude conditionally approving, Codex and Grok rejecting). The production choice between a hardened native runway and synthetic touch is open and will be made on physical-device results. This document is not a new committee vote and not approval to change the production default.
Direction #
A list has one logical position and a visible region; rendering projects items in
that region into a small DOM window. “Camera over virtual space” is useful internal
vocabulary. The public 1D APIs remain scrollToIndex and pixel-equivalent
getScrollPosition.
One logical rendering contract can accept multiple input providers. It does not require replacing native touch. The production choice between a hardened native runway and synthetic touch remains open until measured on physical devices.
Scope is 1D. Native cross-axis overflow (for example, a vertical table's columns) must keep working. 2D virtualization, Z, spatial indexes, new camera APIs, and rubber-band physics are outside this change. Overscan distance/cap work is an independent proposal and is not a prerequisite for this input experiment.
Milestones #
| Milestone | Scope | Version |
|---|---|---|
| Opt-in | scroll.mode: "synthetic" beside native and bounded; existing defaults, configuration and plugin behavior unchanged; shipped as its own entry so the base bundle does not grow for consumers who do not opt in; unsupported combinations (page, carousel wrap until integrated) throw with a clear message; known limitations documented |
2.7 (minor, additive) |
| Default change (3.0 shape, decided 2026-09-14, revised 2026-09-15) | Native scrolling stays the default of vlist; synthetic input is the opt-in vlist/synthetic entry. Bounded mode is removed with scroll.mode, scroll.runway and scale(); huge lists use vlist/synthetic. The carousel's wrap runway moves into the carousel plugin. The custom scrollbar stays a plugin. The 2026-09-14 shape (synthetic default, native as vlist/native) was implemented on next, tested and published as 3.0.0-next.1, then revised; see the decision record. |
3.0 |
The opt-in milestone follows the RFC-012 precedent, where bounded mode shipped as opt-in in 2.x. Real consumer usage of the opt-in mode is an input to the default decision. The A/B comparison informs that decision; it does not block the opt-in release once the Input and Integration gates below are met for the opt-in surface.
3.0 simplification and size goal (revised 2026-09-15). Moving the synthetic driver into the default could not meet the 9.9 KB base gate: after removing bounded mode, scale() and the deprecated hooks, the synthetic default measured 11,670 bytes gzipped, and a read-only analysis of the core found no feature-preserving path to 9.9 KB. With native as the default, the same removals shrink the base instead; a scratch experiment put it near 9.0 KB. Gate: the 3.0 base bundle, native by default, measures at or below the 2.8 base (9.9 KB gzipped); the synthetic entry must not cost more than its 2.8 opt-in; the carousel row may grow by the runway it now carries. The measurement is bun run size, recorded in the release prep.
Current implementation and alternatives #
v2.6.3 defaults to native scrolling; bounded mode is opt-in. The bounded handler
stores absolute logical pixels and a baseOffset; its native scroll callback can
write scrollTop while handling a scroll event. Unit tests cannot certify the
effect of those writes on physical-device momentum. The adapter remains a wrapper
over state; making it canonical is separate work.
| Candidate | Main-axis content | Input risk to measure |
|---|---|---|
| A: hardened native runway | Bounded viewport multiple | Native fling exhausts runway; idle detection/rebase interrupts motion |
| B: synthetic touch | Viewport sized | Gesture ownership, parent scrolling, cancellation, main-thread latency, feel |
/gate-b/ is the existing standalone A experiment, not the shipped v2 handler.
/gate-b/synthetic/ is the source of the standalone B experiment; the deployed
review target is vlist.io/experiments/synthetic/
(?axis=x horizontal, native/ runway comparison). Neither certifies production
plugin compatibility. B is a prototype candidate, not the chosen default.
Synthetic prototype contract #
The prototype uses Pointer Events and a signed gesture sampler. It does not reuse
the unsigned render-speed tracker in vlist/src/core/velocity.ts.
| State | Entry / exit |
|---|---|
| idle | No owned gesture or animation |
| axis-pending | One touch/pen begins; cancel existing animation; wait for 6px intent |
| tracking | Main-axis movement dominates cross-axis by 1.2; own main-axis deltas |
| inertia | Release with fresh signed velocity; integrate exponential decay by elapsed time |
| animating | Programmatic smooth navigation; shares the same animation scheduler |
| cancelled | Cross-axis intent, multitouch, native cancellation, or external navigation during touch; no reacquisition until release |
Rules:
- Vertical uses
touch-action: pan-x pinch-zoom; horizontal usespan-y pinch-zoom. Native cross-axis scrolling and pinch zoom are permitted from gesture start. Pointer cancellation from browser-owned gestures stops synthetic motion. - No synthetic-to-native same-axis touch chaining is claimed. A gesture that reaches a logical edge stops there until lift. With this axis policy, even a new same-axis gesture starting inside an edge-pinned list does not scroll its parent. Start outside the widget to scroll the page. This is a conspicuous experimental limitation and an unresolved product decision, not an accepted regression.
- Wheel at a logical edge is left uncancelled; consumed wheel events do not forward residual delta to the parent. Ctrl-wheel is left to browser zoom.
- A second touch cancels tracking/inertia; no single-finger resumption until all touches end. Pinch permission comes from CSS, not just ignoring the second finger.
- A new touch, wheel, keyboard command, slider drag, or programmatic navigation cancels the preceding animation. Only one animation owns position at a time.
- Resize clamps position and cancels motion. Hidden page/window blur cancels it too. Touch/pointer cancellation never starts inertia. A frame pause over 100ms stops inertia rather than producing a large catch-up jump; programmatic smooth navigation is not abandoned and lands on its target. (Amended 2026-09-13.)
- Release velocity uses signed pointer deltas, a direction-reversal reset and a 80ms freshness limit; clamped to 3px/ms. Inertia decays at 0.006/ms and stops below 0.02px/ms or at bounds. These are trial parameters, not platform parity claims.
- A drag may begin on any descendant, including links and buttons; the intent threshold separates a tap from a drag, as native scrolling does. Only range inputs, text inputs and editable content are excluded, because a drag has its own meaning there. A tap that interrupts inertia or an animation produces no click. Click suppression is per gesture, not a timer. Sortable/selection plugin arbitration remains an integration gate. (Amended 2026-09-13: the prototype excludes all interactive descendants and uses a 500 ms window; both are review defects, see below.)
- Main-axis content is viewport-sized. Rendering never writes native main-axis
scroll position. The clipping layer is a separate element from the native
cross-axis scroller: the viewport keeps
overflow-x: auto; overflow-y: hidden(or the converse), and the stage inside it usesoverflow: clipon both axes so it is not a scroll container and its clipped rows do not contribute to the viewport's scrollable overflow. Settingclipon one axis of the viewport itself does not work;autoplusclipcomputes tohidden. Tests assert both the computedoverflowof the stage and that focusing an off-viewport row leaves every main-axisscrollTopat 0. Cross-axis header sync reads native cross-axis scroll. - Keyboard navigation works while any non-interactive descendant has focus, not only when the viewport itself is focused.
- Clock contract: input samples and release freshness use
event.timeStamp; animation progress uses animation-frame timestamps. Inertia starts its frame clock on the first animation frame after release, not at the release timestamp, so delayed event delivery or a slow first frame cannot trip the 100 ms suspension rule before motion has begun. The suspension rule measures gaps between animation frames only.
These decisions are testable. There is no “50 lines + 10 lines” scope estimate.
Approval and release gates #
Architecture approval accepts the logical contract and a bounded experiment. Specification approval requires explicit interaction choices and integration plans. Release approval requires implemented, tested behavior. They are separate.
| Gate | Required before removing the native viewport path |
|---|---|
| Input | Physical iPhone/Safari and Android/Chrome results; explicit accept/reject of boundary behavior, axis locking and zoom; candidate chosen |
| Integration | Table cross-axis pan/header sync, autosize anchoring, carousel wrap, groups, snapshots, sortable/selection and programmatic interruption verified against the chosen driver |
| Scrollbar | Default/disable/customization/duplicate-install API specified; keyboard, focus, pointer, screen-reader and forced-colors checks implemented; measured net bundle cost |
| Page | Native document provider behind the external-scroll seam; retain size guard with useful message; carousel conflict remains explicit |
| RTL | Supported and tested axis semantics or explicit unsupported guard; don't retain runway-specific requirements if synthetic wins |
| Migration | Deprecation/migration for scroll.mode and native scrollbar configuration; API compatibility, full tests, size and browser checks |
The prototype's native range input is a test control, not completion of the production virtual-scrollbar accessibility gate. Passing DOM snapshots alone does not certify screen-reader behavior.
No requirement to migrate every direct state read to the adapter precedes release. Audit main-axis behavior and route ownership through the chosen contract; native cross-axis reads are legitimate. Adapter canonicalization can follow independently.
Decision record and verification #
2026-09-11 — positive device feedback. The user tested the deployed synthetic experiment on both iOS and Android and reported that it “works beautifully.” This supports continuing candidate B evaluation. It is an overall qualitative result; device/browser versions, individual checklist outcomes and telemetry were not supplied. Integration follows the recorded decision in the plan, not this report. No production plugin, scrollbar-accessibility, native A comparison, or default-change gate is marked complete by it.
2026-09-13 — prototype review. A code review of the deployed prototype (vlist.io commit c5d28d6) reproduced these defects with Chrome touch emulation against production. Each must be fixed and covered by the browser harness before the Input gate can close:
- Focusing a link in an overscan row scrolls the stage natively (
overflow: hiddenis a scroll container); the model and the “native main scroll” counter see 0. - A drag starting on a link or button never scrolls.
- A tap that catches a fling more than 500 ms after release fires a click on the row underneath.
- Arrow keys do nothing while a row descendant has focus.
- A frame pause over 100 ms abandons smooth navigation short of its target.
Not defects, but decisions the review makes explicit: the maximum fling travel
(3 px/ms clamp, 0.006/ms friction) is about 500 px, roughly a third of iOS default
deceleration, and must be accepted or retuned on device; the driver and the browser
each apply their own axis-intent slop, so diagonal starts move the list a few pixels
before a cross-axis pointercancel. Integration blockers identified in vlist core:
sortable takes pointerdown on the same element with a 5 px threshold and no
touch-action, and autosize/transition corrections route through a cancelling
scrollTo, so the driver needs a non-cancelling shift primitive.
2026-09-13 — boundary policy for the opt-in mode: accepted. Same-axis touch in synthetic mode stops hard at either list edge and does not hand the gesture to the parent, including a gesture that begins inside an already edge-pinned list. This is accepted for the 2.7 opt-in mode only, on Codex's recommendation in #127, and must be documented as a known limitation of that mode; applications that need native parent scrolling keep the existing native mode. Accepting it for an opt-in feature does not approve it as a default. Parent chaining, if ever required, is a different ownership design to be specified and measured separately. The per-device result sheet (devices, OS and browser versions, exported telemetry) is still to be recorded before the opt-in release.
2026-09-14 — opt-in implementation complete on feat/synthetic-input. Codex
implemented and Claude reviewed three PRs (floor/vlist#130, #131, #132): the
vlist/synthetic entry with scroll.mode: "synthetic", the non-cancelling
shiftBy with autosize and transition corrections routed through it, and the
guards plus documentation. Verified from a clean export: 3530 tests, typecheck,
base bundle unchanged at 9.8 KB, synthetic entry +2.5 KB gzipped (accepted
budget), frame timing and main-thread cost per pixel on par with bounded mode.
The five prototype defects are fixed and live. Not yet done: RTL policy for the
driver, the official benchmark scenario, the per-device result sheet, and the
merge into staging for the 2.7 minor, which is jvial's decision.
2026-09-14 — Input gate: candidate B chosen; candidate A rejected on device. jvial ran the native runway comparison (candidate A: bounded runway 16x viewport, idle-only rebase at 160 ms, 1,000,000 rows of 52 px) on a physical iPhone and a physical Android phone. The page's own three pass conditions are: no scrollTop writes during momentum, zero hard-edge hits, and a single fling shorter than the runway. Results:
| Platform | Writes during momentum | Hard-edge hits | Max fling | Runway | Fling vs runway | Frame gaps >32 ms |
|---|---|---|---|---|---|---|
| iPhone SE (2nd gen, 2020), iOS 26.6.1, Safari | 0 | 955 | 35,375 px | 16,470 px | 215% | 10 |
| Pixel 8a, Android 17, Chrome | 0 | 751 | 27,560 px | 12,180 px | 226% | 1 |
| iPhone SE (2nd gen), iOS 26.6.1, Chrome for iOS (WebKit) | 0 | 361 | 11,793 px | 8,145 px | 145% | 0 |
The idle-only rebase policy held (no writes during momentum), but a single native
fling travels more than twice the runway on both platforms and pins against the
runway edge for hundreds of frames: the fling stops dead mid-momentum, as the June
committee predicted. A 40x runway would be needed to contain these flings, and the
plan does not accept a larger factor as proof. Candidate A is rejected. Candidate B
(synthetic touch) was reported as working well on both devices; its opt-in
implementation is complete on feat/synthetic-input. The iPhone SE is a 2020
device, which covers the plan's low-end responsiveness item for iOS.
Candidate B telemetry, exported from the prototype page on the iPhone SE in Chrome for iOS (2026-09-14, vertical axis, 313x320 viewport at 2x): frame gaps over 32 ms 0, boundary contacts 0, paint-coverage failures 0, native main-axis scroll events 0, 13 rendered nodes for 1,000,000 rows, peak velocity 2.9 px/ms, 15 pointer cancellations (WebKit taking cross-axis pan or pinch, the documented arbitration), 2 multitouch cancellations with recovery. Flings settle to idle; slider and navigation cancel motion as specified.
2026-09-14 — wheel judder in bounded and synthetic mode: root cause fixed in 2.6.5.
jvial found trackpad deceleration jerky in both logical modes on a 120 Hz display. The
cause was a pre-existing core defect, not the synthetic driver: the pipeline's
range-unchanged fast path skipped the DOM commit while baseOffset carried the motion,
so rows stood still until the range crossed a row boundary. Fixed in floor/vlist#135
(v2.6.5), merged into feat/synthetic-input; the pointer-fling benchmark now asserts on
rendered row position (vlist.io#63). Full record, including the investigation's wrong
turns: issue 025.
2026-09-14 — 2.7.0 released. vlist/synthetic with scroll.mode: "synthetic" is
published as an opt-in entry (+2.6 KB gzipped; base 9.9 KB). Ships with the supported
plugin set (table, groups, snapshots, scrollbar, autosize, transition, selection, a11y),
guards for page, carousel, sortable and RTL horizontal lists, the documented boundary
policy, and the 2.6.5 pipeline fix. Native remains the default. Next: framework
adapter support for the entry, a vlist.io docs page for the mode, and consumer feedback
toward the conditional 3.0 decision.
Implementation sequence and live checklists: implementation plan. Prototype usage, telemetry and physical-device result sheet: prototype README.
Keep current defaults until the gates close. If B's interaction compromises are not acceptable, continue A testing or retain the current opt-in deployment. No deadline, line-count estimate, emulator result, or committee vote substitutes for device data.
Historical lineage: Unified draft, native-runway plan, integrated proposal, and #117.
Platform contract: W3C Pointer Events, touch-action.
Browser gesture policy is established at gesture start; stopping preventDefault
later is not a specified mechanism for transferring a captured gesture to a parent.
pointermove is cancelable, but cancelling it does not affect native panning or
zooming; only touch-action, fixed at gesture start, does. The static touch-action
policy above is therefore the only ownership mechanism this driver has, and it cannot
provide same-axis parent chaining. If chaining is ever required, it needs a different
ownership design, for example Touch Events with a look-ahead before the first
preventDefault, or synthesized parent scrolling; either is a new driver design to
be specified and measured, not a parameter of this one.
2026-09-15 — 3.0 shape revised: native default, synthetic opt-in. The 2026-09-14
shape was implemented on next (floor/vlist#157, the flip; #160, the removals), passed
two local test rounds on every vlist.io example and a migration of the radiooooo desk, and
was published as 3.0.0-next.1. Its default bundle measured 11,670 bytes gzipped against
the 9.9 KB gate, and a read-only analysis of the core by Claude and Codex found no path to
the gate without removing features. Dr Jones then chose native as the 3.0 default. It
gives the smallest default bundle and the smallest break from 2.x, where native is the
default; it keeps native browser features and assistive-technology scrolling by default;
and synthetic input stays one import away through vlist/synthetic for huge lists,
touch-heavy apps and cross-browser consistency. The test rounds showed that synthetic input
works well, not that every list needs it. Bounded mode, scroll.mode, scroll.runway,
scale() and the deprecated plugin hooks stay removed. Implementation is series 8 on
next; a 3.0.0-next.2 prerelease supersedes next.1.