Make gestures cooperate with scrolling and feel smoother

From an app built on core-v2:

Scrolling (#3, #4, #14)
- Each recognizer locks onto a touch within its first 6px: along its axis
  and direction it holds the touch, otherwise it lets go. A new
  onTouchmove binding cancels native scrolling only while a recognizer
  holds the touch, so hosts use touch-action: manipulation and one
  vertical gesture no longer disables vertical scrolling. Several
  gestures on one host share touches through the same rule.
- .from.scrollEdge(scroller, edge) admits drag-to-dismiss only while a
  scroller is at the edge (#5).

Feel
- recognition.distance sets what counts as full progress: pixels, a CSS
  length, or a measuring function (#6).
- Choreographies receive overshoot, the drag past either end of the
  clamped progress, and the settling spring starts from it (#7).
- Velocity is the least-squares trend of the last 100ms (#8).
- Defaults retuned to commitThreshold 0.25 and commitVelocity 0.2; the
  slide presets use them (#9).
- Choreographies may set spring stiffness and damping (#10).

Hooks and helpers
- .onArm()/.onDisarm() run during a drag when releasing would start or
  stop committing (#12).
- startedIn, nearEdge, atScrollEdge, anyOf, allOf start predicates (#15).
- Development builds warn when an operation's first frame moves a node
  that was visibly at rest, the one-frame "pop" of mismatched open and
  close choreographies (#16).

Docs: scrolling, ignored elements, animation performance, haptics, and
operating-system edge gestures including CloseWatcher/popstate for PWAs
(#11, #17, #18).

Verification: a Chromium harness with emulated touch (verify/browser)
checks swipes over scrollers, native scrolling beside gestures,
scroll-edge dismissal, onCommit inside pointerup, and the continuity
warning. Reverting the earlier capture fix makes it fail exactly as
reported ("none of the handles work").

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Faarh2Ts6StJPcz2mRiHiR
This commit is contained in:
Claude
2026-10-31 08:22:11 +00:00
parent d4d7feb3b7
commit 6889fc0112
20 changed files with 1547 additions and 163 deletions
+227 -25
View File
@@ -20,6 +20,7 @@ This document describes the experimental `0.1.0-experimental.0` API.
- [Scene diagnostics and manual operations](#scene-diagnostics-and-manual-operations)
- [Type reference](#type-reference)
- [Errors and constraints](#errors-and-constraints)
- [Gestures in real apps](#gestures-in-real-apps)
## Gesture start recognition
@@ -43,6 +44,7 @@ gesture.from.top(distance);
gesture.from.bottom(distance);
gesture.from.anywhere();
gesture.from.when(predicate);
gesture.from.scrollEdge(scroller, edge);
```
`distance` accepts a number in CSS pixels or a CSS length string, including
@@ -80,9 +82,43 @@ const dropDialog = gesture.from
.animate(dropAnimation);
```
Interactive form controls are ignored automatically. Add
`.from.scrollEdge(scroller, edge)` admits pointer-down only while a scroll
container is scrolled all the way to `edge`. It is the drag-down-to-dismiss
rule for sheets and pushed pages: at the top, dragging down dismisses and
dragging up still scrolls; anywhere else the list just scrolls. `scroller` is
an element, a ref or getter returning one, or a CSS selector resolved inside
the gesture host. A scroller that is not mounted counts as at the edge.
```ts
const list = ref<HTMLElement | null>(null);
const dismiss = gesture.from
.scrollEdge(list, "top")
.to.down()
.navigate((context) => (context.canGoBack ? back() : null))
.animate(sheetDown);
```
Start rules also come as composable predicates for `.from.when()`:
| Helper | Admits pointer-down |
| ------------------------------ | ------------------------------------------- |
| `startedIn(selector)` | On or inside an element matching `selector` |
| `nearEdge(edge, distance)` | Within `distance` of one side of the host |
| `atScrollEdge(scroller, edge)` | While `scroller` is scrolled to `edge` |
| `anyOf(...predicates)` | When any predicate does |
| `allOf(...predicates)` | When every predicate does |
```ts
gesture.from
.when(anyOf(startedIn(".sheet-handle"), atScrollEdge(list, "top")))
.to.down();
```
Interactive form controls (`input`, `textarea`, `select`, `option`, and
`[contenteditable="true"]`) are ignored automatically. Add
`data-origin-gesture="ignore"` to any other element or ancestor that should not
begin a gesture.
begin a gesture, such as a horizontally scrolling chip row inside a host with a
horizontal gesture.
## Minimum setup
@@ -184,15 +220,13 @@ const gestures = [forwardGesture, backGesture] as const;
| `gestures` | `readonly OriginGestureDefinition[]` | required | Fully defined page-owned rules |
The component adds no recognition or navigation policy. It installs each
definition with `useOriginGesture()`, forwards every pointer event to every
binding, and derives the least-permissive shared `touch-action`:
definition, forwards every pointer and touch event to every recognizer, and
lets the recognizers share the touch: each locks onto a touch moving along its
own axis and direction and lets go of any other, so a horizontal drawer, a
pull-up sheet, and vertical scrolling can live on one host. See
[Scrolling](#scrolling).
- horizontal only: `pan-y`;
- vertical only: `pan-x`;
- both axes: `none`.
The `gestures` list is reactive, including its length and the derived
`touch-action`. Recognizers are kept per list position, so a re-render with new
The `gestures` list is reactive, including its length. Recognizers are kept per list position, so a re-render with new
definition objects never interrupts a gesture in progress; each change applies
from the next pointer-down. The owning page remains the visible declaration
point for every `.from`, `.to`, `.complete`, `.navigate`, and `.animate` choice.
@@ -427,6 +461,16 @@ change a definition already produced from it.
| --------------- | ------- | ------------------------------------------------- |
| `threshold` | `8` | Directed CSS pixels required before capture |
| `axisDominance` | `1.15` | Directed/cross-axis ratio required before capture |
| `distance` | host | Movement that counts as full progress |
`distance` is resolved at pointer-down: a number in pixels, a CSS length
(percentages are of the host), or a function `({ host, bounds }) => pixels`.
Set it when the moving element is smaller than the host, such as a drawer 84%
of the screen wide, so the drawer stays under the finger:
```ts
gesture.to.right({ distance: () => drawer.value?.offsetWidth ?? 300 });
```
If `.complete()` is omitted, the choreography's `commitThreshold` and
`commitVelocity` decide release normally.
@@ -452,10 +496,30 @@ const openProfile = gesture.to
.animate(slideLeft);
```
`navigator.vibrate` is unavailable in iOS Safari; Capacitor apps can call
`Haptics.impact()` from `@capacitor/haptics` instead. The method is named
`onCommit` rather than `then` so builder stages never become promise-like
"thenables" when awaited or returned from an async function.
Handlers run inside the `pointerup` event whenever the operation has already
begun, which is the usual case, so APIs that need the user's gesture (iOS
haptics, audio, clipboard writes) work there. See [Haptics](#haptics).
The method is named `onCommit` rather than `then` so builder stages never
become promise-like "thenables" when awaited or returned from an async
function.
`.onArm(handler)` and `.onDisarm(handler)` follow the same rules and run during
the drag: `onArm` when releasing would start to commit, `onDisarm` when it no
longer would. Use them for a "release now" cue or haptic. Arming follows
distance (`progress >= commitThreshold`) because velocity changes too quickly to
drive a cue without flickering; when the definition has a `.complete()`
predicate, that predicate decides arming instead.
```ts
gesture.to
.left()
.navigate(() => forward(originView(Archive)))
.onArm(() => haptic("light"))
.onDisarm(() => haptic("soft"))
.onCommit(() => haptic("medium"))
.animate(slideLeft);
```
### `useOriginGesture(definition)`
@@ -482,22 +546,32 @@ interface OriginGestureBinding {
readonly onPointerup: (event: PointerEvent) => void;
readonly onPointercancel: () => void;
readonly onLostpointercapture: (event: PointerEvent) => void;
readonly onTouchmove: (event: TouchEvent) => void;
}
```
Apply all handlers to the same element. The returned style sets dimensions and
`touch-action` so native scrolling remains available on the cross-axis.
Apply all handlers to the same element, as non-passive listeners (Vue's
`@touchmove` is), and apply the returned style. The style sets dimensions and
`touch-action: manipulation`; `onTouchmove` is what keeps native scrolling out
of a gesture the recognizer has claimed. Without it, the browser takes over
vertical and horizontal drags with `pointercancel`.
Recognition requires:
1. A primary, left-button pointer satisfies the optional start policy.
2. The target is not an ignored interactive element.
3. Directed movement reaches `threshold`.
4. Directed movement exceeds cross-axis movement by `axisDominance`.
5. The navigation factory returns an intent.
3. The first 6px of movement are along the gesture's axis and direction (see
[Scrolling](#scrolling)).
4. Directed movement reaches `threshold`.
5. Directed movement exceeds cross-axis movement by `axisDominance`.
6. The navigation factory returns an intent.
Progress is directed distance divided by host width or height. Release velocity
is normalized by the same dimension.
Progress is directed distance divided by the gesture's `distance` (by default
the host's width or height). Past either end, the excess reaches
choreographies as `overshoot`. Release velocity is normalized by the same
distance and measured as the trend of the last 100ms of samples, so uneven
touch timing does not distort it and a pointer held still before release has
no flick.
An asynchronous navigation factory is supported. If it resolves after the
pointer was released or cancelled, the stale result is discarded.
@@ -585,10 +659,12 @@ can run repeatedly during rendering and animation.
When `finish()` does not explicitly override the decision, the release velocity
decides first, then distance:
- a flick at least `commitVelocity` fast (default `0.3` progress units per
second) decides by its direction: forward commits, backward cancels,
regardless of progress;
- a slower release commits when `progress >= commitThreshold`, default `0.36`.
- a flick at least `commitVelocity` fast (default `0.2` progress units per
second, about 80px/s across a 390px phone) decides by its direction: forward
commits, backward cancels, regardless of progress;
- a slower release commits when `progress >= commitThreshold`, default `0.25`.
The included presets use these defaults.
Velocity is normalized by the gesture host's size, so `1` is one full width (or
height) per second.
@@ -778,6 +854,21 @@ Node-local action context:
| `originRect` | Origin bounds captured before target mounting |
| `targetRect` | Target bounds measured after mounting |
| `viewport` | Scene-container bounds, with browser viewport fallback |
| `overshoot` | Drag distance past either end of `progress`, else `0` |
`overshoot` is negative when a drag is pulled back beyond the start and
positive beyond full progress. Use it for rubber-banding:
```ts
effects: ({ progress, overshoot }) => ({
target: {
transform: `translateX(${(1 - progress) * 100 - overshoot * 20}%)`,
},
});
```
The settling spring starts from the overshot position, so a rubber-banded drag
springs back continuously.
Rect values are viewport CSS pixels.
@@ -831,3 +922,114 @@ cause:
- A choreography creates one target. Chaining supports any number of
simultaneously mounted targets.
- Reduced-motion preference resolves settling immediately.
## Gestures in real apps
### Scrolling
Gestures and native scrolling share the same touches. Each recognizer decides
within the first 6px of movement whether a touch is its own:
- **along its axis and direction**: it holds the touch, and the binding's
`onTouchmove` cancels native scrolling while it does;
- **along the other axis, or the opposite way**: it lets go, and the browser
scrolls (or another gesture on the host takes it);
- **diagonal**: it keeps watching until the movement is clear.
Browsers hold back the first cancelable `touchmove` for roughly 15–20px, so the
decision is made before native scrolling can start. This is why gesture hosts
use `touch-action: manipulation`: the browser may pan either axis, and the
recognizers keep it from doing so only for touches they claim. A vertical
dismiss gesture therefore no longer disables vertical scrolling; combine it
with `.from.scrollEdge()` so it only claims a downward drag at the top.
Nested scrollers need nothing special. Their `touchmove` events bubble to the
gesture host, which cancels them only while it holds the touch.
### Ignored elements
Pointer-down on `input`, `textarea`, `select`, `option`, or
`[contenteditable="true"]` never starts a gesture, so text selection and
form controls keep working. Mark anything else that must keep its own drags
with `data-origin-gesture="ignore"`, for example a horizontally scrolling chip
row or a slider inside a host that has a horizontal gesture.
### Animation performance
Animate `transform` and `opacity` only. Browsers composite them without
layout or paint. Animating `filter`, `clip-path`, `box-shadow`, or
`border-radius` through `effect.style` repaints every frame and is the usual
cause of janky transitions; if you need one of them, set it once at rest
rather than per frame.
### Continuity between choreographies
In development builds the scene warns when an operation's first frame moves a
node that was visibly at rest. This catches the one-frame "pop" when a close
choreography's progress-0 effects do not match the pose the previous
choreography left behind, typically a `persistAtRest` drawer or sheet whose
open (at 1) and close (at 0) effects disagree. The check runs only in
development (`process.env.NODE_ENV !== "production"`, which bundlers replace)
and only in browsers, since it reads rendered styles.
### Haptics
`onArm`, `onDisarm`, and `onCommit` run inside the pointer event, which is
where platforms allow feedback:
- **Android, Chrome**: `navigator.vibrate(10)`.
- **iOS Safari**: there is no Vibration API. Haptics are only reachable
through a native wrapper, or through an undocumented technique: toggling a
hidden `<input type="checkbox" switch>`, which iOS 18+ Safari renders as a
switch with a system haptic. It only produces a haptic during a user gesture,
so call it from these handlers, never after an `await`, and expect it to
change between iOS releases.
- **Capacitor**: `Haptics.impact()` from `@capacitor/haptics`.
### Operating-system edge gestures
The screen edges belong to the operating system first. On iOS, a swipe from the
left edge goes back in Safari's history, including in apps installed to the
home screen (`display: standalone`), which have no back button. On Android
with gesture navigation, a swipe from either side edge is the system back
gesture. Pages cannot claim those touches, so `.from.left()` or `.from.right()`
with a narrow zone competes with them. Prefer a zone that starts a little
inside the edge, or a wider one (`.from.when(nearEdge("left", 56))`), and give
every back gesture an on-screen alternative.
Web apps cannot disable the operating system's gesture, but an installed
progressive web app can make it do the right thing, so it backs out of the
scene instead of leaving the app:
- **Android (Chrome 120+)**: listen for close requests with `CloseWatcher`.
The system back gesture and back button send one; route it to the scene's
back operation. A watcher must be created during a user activation (a tap
or a gesture), and after it fires it cannot fire again until the user
interacts again, so recreate it after each navigation:
```ts
let watcher: CloseWatcher | undefined;
function watchSystemBack(origin: UseOrigin) {
watcher?.destroy();
if (!("CloseWatcher" in window) || !origin.canGoBack.value) return;
watcher = new CloseWatcher();
watcher.onclose = () => origin.perform(back(slideRight));
}
// Call from a gesture's .onCommit() or a click handler, after navigating.
```
Without `CloseWatcher`, push a history entry when the scene navigates
forward (`history.pushState({ scene: true }, "")`) and handle `popstate` by
performing the scene's back. The system gesture then pops that entry instead
of leaving the app.
- **iOS (standalone)**: the edge swipe navigates browser history. Scenes never
add history entries, so in an installed app with no history to go back to
the swipe should have nothing to do. If the app also uses the History API,
map `popstate` to the scene's back as above.
- **Browser history swipes** (two-finger trackpad swipes, and horizontal
overscroll navigation in Chromium): `overscroll-behavior-x: none` on the page's
scroll container stops horizontal overscroll from triggering navigation.
Verify the behaviour on real devices: these platform gestures are not part of
any web standard and change between releases.