The previous round regressed the demo against main: dropped frames while a finger drags, and swipes ignored when they start imperfectly. Frame drops: hosts used touch-action: manipulation plus a blocking touchmove listener, so Chrome waited for JavaScript on every touch move (~30% of frames over 20ms in a drag, p95 28ms). Hosts again use per-axis touch-action like main (pan-y for horizontal gestures, and so on), and attach no touch listener. Flaky recognition: an irreversible decision after 6px dropped swipes with a wobbly or backward start. Recognition again measures from the pointer-down point until the pointer lifts, as on main. Scrolling beside gestures, natively: - .from.scrollEdge() uses direction-specific touch-action while its scroller is at the edge (pan-x pan-down for drag-down at the top), the pull-to-refresh pattern from the Pointer Events spec. A passive scroll listener keeps it current; no touch handling blocks the browser. - Where directional values are unsupported (Safari), scroll-edge gestures alone fall back to claiming touches at the browser's first cancelable touchmove. - Hosts publish touch-action as --nvo-touch-action and the stylesheet applies it to descendants, so nested scroll containers keep it and horizontal swipes over lists are not cancelled. Ignored subtrees get touch-action: auto back. - Bindings gain listeners and ref (spread on the host); the components use them. Feel tests (npm run test:feel): Chromium with emulated touch checks recognition on ten human-like traces, frame pacing budgets while dragging (p95 <= 20ms, <= 5% over 20ms) for a swipe and a scroll-edge dismiss, and the behaviour checks. Against the previous code they fail on exactly the reported regressions; now 23/23 pass. On the demo, all traces match main and drag frame pacing matches or beats it. Commit thresholds are unchanged (0.25 distance, 0.2 flick). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Faarh2Ts6StJPcz2mRiHiR
1074 lines
42 KiB
Markdown
1074 lines
42 KiB
Markdown
# Core v2 API reference
|
||
|
||
`@native-vue-router/core-v2` is a routeless Vue scene compositor. A mounted
|
||
component can create another component, animate both through a local operation
|
||
frame, and retain either side when the operation resolves.
|
||
|
||
This document describes the experimental `0.1.0-experimental.0` API.
|
||
|
||
## Contents
|
||
|
||
- [Gesture start recognition](#gesture-start-recognition)
|
||
- [Minimum setup](#minimum-setup)
|
||
- [Components](#components)
|
||
- [Nested scenes](#nested-scenes)
|
||
- [Scene and view functions](#scene-and-view-functions)
|
||
- [Actions and history](#actions-and-history)
|
||
- [Gestures](#gestures)
|
||
- [Choreographies and effects](#choreographies-and-effects)
|
||
- [Node-scoped controls](#node-scoped-controls)
|
||
- [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
|
||
|
||
The builder separates where a gesture begins from the direction it moves:
|
||
|
||
```ts
|
||
const edgeBack = gesture.from
|
||
.left("clamp(24px, 7vw, 48px)")
|
||
.to.right()
|
||
.navigate((context) => (context.canGoBack ? back() : null))
|
||
.animate(slideRight);
|
||
```
|
||
|
||
Edges are measured from the **gesture host element**, not unconditionally from
|
||
the browser viewport. Supported start rules are:
|
||
|
||
```ts
|
||
gesture.from.left(distance);
|
||
gesture.from.right(distance);
|
||
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
|
||
percentages, `calc()`, and `clamp()`. It is resolved against the current host
|
||
size at pointer-down.
|
||
|
||
`.from` is optional. A chain beginning at `.to` admits pointer-down anywhere:
|
||
|
||
```ts
|
||
gesture.to.right();
|
||
```
|
||
|
||
This is semantically equivalent to:
|
||
|
||
```ts
|
||
gesture.from.anywhere().to.right();
|
||
```
|
||
|
||
It does not capture on the first positive pixel. The recognizer waits until
|
||
directed movement crosses the intent threshold and dominates the cross-axis.
|
||
|
||
Custom shapes, safe-area rules, and exclusion zones belong in `.from.when()`:
|
||
|
||
```ts
|
||
const dropDialog = gesture.from
|
||
.when(({ point, bounds, event }) => {
|
||
const rail = Math.max(36, bounds.width * 0.08);
|
||
const outsideExcludedBand =
|
||
event.clientY < bounds.top + bounds.height * 0.35 ||
|
||
event.clientY > bounds.top + bounds.height * 0.65;
|
||
return point.localX <= rail && outsideExcludedBand;
|
||
})
|
||
.to.down()
|
||
.navigate(() => above(originView(DialogView)))
|
||
.animate(dropAnimation);
|
||
```
|
||
|
||
`.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, such as a horizontally scrolling chip row inside a host with a
|
||
horizontal gesture.
|
||
|
||
## Minimum setup
|
||
|
||
Import the required compositor stylesheet once:
|
||
|
||
```ts
|
||
import "@native-vue-router/core-v2/style.css";
|
||
```
|
||
|
||
Create a scene:
|
||
|
||
```ts
|
||
import { createOriginScene, originView } from "@native-vue-router/core-v2";
|
||
import HomeView from "./HomeView.vue";
|
||
|
||
export const scene = createOriginScene({
|
||
initial: originView(HomeView, undefined, {
|
||
key: "home",
|
||
name: "Home",
|
||
}),
|
||
});
|
||
```
|
||
|
||
Render it:
|
||
|
||
```vue
|
||
<script setup lang="ts">
|
||
import { OriginScene } from "@native-vue-router/core-v2";
|
||
import { scene } from "./scene";
|
||
</script>
|
||
|
||
<template>
|
||
<OriginScene :scene="scene" />
|
||
</template>
|
||
```
|
||
|
||
`OriginScene` must have a non-zero width and height through its parent layout.
|
||
|
||
## Components
|
||
|
||
### `OriginScene`
|
||
|
||
Renders every currently mounted scene node as a stable, absolutely positioned
|
||
sibling.
|
||
|
||
| Prop | Type | Required | Description |
|
||
| ------- | ------------- | -------- | -------------------------------------- |
|
||
| `scene` | `OriginScene` | yes | Scene created by `createOriginScene()` |
|
||
|
||
The component provides node ownership to descendants, registers host elements
|
||
for measurement, and applies the scene's composited styles. Application views
|
||
must be rendered through this component before calling `useOrigin()` or
|
||
`useOriginGesture()`.
|
||
|
||
### `OriginGesture`
|
||
|
||
Convenience component that renders one HTML element and binds one gesture
|
||
recognizer to it.
|
||
|
||
| Prop | Type | Default | Description |
|
||
| --------- | ------------------------- | ------- | ---------------------------------- |
|
||
| `as` | `string` | `"div"` | HTML tag used for the gesture host |
|
||
| `gesture` | `OriginGestureDefinition` | — | Builder definition |
|
||
|
||
Attributes, classes, and listeners not consumed as props are forwarded to the
|
||
rendered host.
|
||
|
||
```vue
|
||
<OriginGesture as="main" class="profile" :gesture="openDetailsGesture">
|
||
...
|
||
</OriginGesture>
|
||
```
|
||
|
||
The `gesture` prop is reactive. A new definition applies from the next
|
||
pointer-down; a gesture already in progress keeps the definition it started
|
||
with, so a parent re-render never interrupts a drag.
|
||
|
||
Use `useOriginGesture()` instead when an extra wrapper is undesirable.
|
||
|
||
### `OriginGestureSurface`
|
||
|
||
Policy-neutral host for multiple completed builder definitions:
|
||
|
||
```vue
|
||
<script setup lang="ts">
|
||
const gestures = [forwardGesture, backGesture] as const;
|
||
</script>
|
||
|
||
<template>
|
||
<OriginGestureSurface as="main" :gestures="gestures">
|
||
...
|
||
</OriginGestureSurface>
|
||
</template>
|
||
```
|
||
|
||
| Prop | Type | Default | Description |
|
||
| ---------- | ------------------------------------ | -------- | ------------------------------ |
|
||
| `as` | `string` | `"div"` | Shared native gesture host |
|
||
| `gestures` | `readonly OriginGestureDefinition[]` | required | Fully defined page-owned rules |
|
||
|
||
The component adds no recognition or navigation policy. It installs each
|
||
definition, forwards every pointer event to every recognizer, and derives one
|
||
`touch-action` that leaves native scrolling on every pan no gesture needs. A
|
||
horizontal drawer and a scroll-edge pull-down can share a host with vertical
|
||
scrolling. See [Scrolling](#scrolling).
|
||
|
||
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.
|
||
|
||
## Nested scenes
|
||
|
||
`OriginScene` is a reusable compositor, not an application-only singleton. A
|
||
component may render another scene inside its own layout:
|
||
|
||
```vue
|
||
<section class="carousel">
|
||
<OriginScene :scene="carouselScene" />
|
||
</section>
|
||
```
|
||
|
||
The child scene gets independent mounted nodes, retained history, measurements,
|
||
operations, and clipping. `useOrigin()` and `useOriginGesture()` resolve the
|
||
nearest scene-node provider, so declarations inside a carousel slide operate
|
||
on carousel components rather than the outer page.
|
||
|
||
Parent/child gesture arbitration is currently pointer-down based. An eligible
|
||
child recognizer stops propagation immediately. If its later `.navigate()`
|
||
factory returns `null`, that same pointer sequence is not offered to the
|
||
parent. Cooperative nested components should reserve a start region with
|
||
`.from.when()` or `.from.left()` that allows the parent handler to receive
|
||
pointer-down.
|
||
|
||
The nested-scenes demo contains both this cooperative policy and an intentional
|
||
greedy-child conflict. A future gesture arena could delay ownership until
|
||
direction and navigation availability are known.
|
||
|
||
## Scene and view functions
|
||
|
||
### `originView(component, props?, options?)`
|
||
|
||
Creates a lightweight recipe for mounting a Vue component.
|
||
|
||
```ts
|
||
const profile = originView(
|
||
ProfileView,
|
||
{ userId: "42" },
|
||
{ key: "profile-42", name: "Profile" },
|
||
);
|
||
```
|
||
|
||
The recipe is not itself a mounted instance. A forward action creates an
|
||
instance from it, then retains that exact instance while its entry remains in
|
||
history. Back does not call the recipe again. Component definitions are marked
|
||
raw so Vue does not proxy them inside reactive scene structures.
|
||
|
||
`OriginViewOptions`:
|
||
|
||
| Field | Type | Description |
|
||
| ------ | -------- | --------------------------------------------- |
|
||
| `key` | `string` | Recipe identity and generated node-key prefix |
|
||
| `name` | `string` | Human-readable diagnostic label |
|
||
|
||
When no key is supplied, one is generated from the component/name and a
|
||
sequence number.
|
||
|
||
### `createOriginScene(options)`
|
||
|
||
Creates one independent scene graph, history context, and compositor.
|
||
|
||
```ts
|
||
const scene = createOriginScene({
|
||
initial: originView(HomeView),
|
||
});
|
||
```
|
||
|
||
`options.initial` accepts one `OriginView` or an array of independent root
|
||
views. Scenes do not share nodes, history, operation IDs, or measurements.
|
||
|
||
## Actions and history
|
||
|
||
### `forward(target, choreography?, options?)`
|
||
|
||
With choreography, creates a complete retained-history push action:
|
||
|
||
```ts
|
||
const openProfile = () =>
|
||
forward(originView(ProfileView, { userId: "42" }), slideLeft);
|
||
```
|
||
|
||
Without choreography, it creates an `OriginNavigationIntent` for a gesture
|
||
builder:
|
||
|
||
```ts
|
||
.navigate(() => forward(originView(ProfileView, { userId: "42" })))
|
||
```
|
||
|
||
When forward commits, the origin remains mounted but becomes parked. Its DOM,
|
||
component-local state, and nested scroll positions remain intact.
|
||
|
||
`OriginNavigationActionOptions.placement` defaults to `"above"`.
|
||
|
||
### `back(choreography?, options?)`
|
||
|
||
Creates a retained-history pop action:
|
||
|
||
```ts
|
||
const goBack = (context: OriginContext) =>
|
||
context.canGoBack ? back(slideRight) : null;
|
||
```
|
||
|
||
Back has no target recipe. When it begins, the scene resolves the origin's
|
||
`previousNodeKey` and reveals that exact mounted instance. A committed back
|
||
unmounts only the current entry. A cancelled back hides the previous entry
|
||
again and leaves the current entry active.
|
||
|
||
`back()` without choreography returns an animation-free navigation intent for
|
||
`.navigate()`. `back(slideRight)` returns a complete programmatic action.
|
||
|
||
`OriginNavigationActionOptions.placement` defaults to `"under"`.
|
||
|
||
### `replace(target, choreography?, options?)`
|
||
|
||
Creates a new target while removing the current history entry:
|
||
|
||
```ts
|
||
const confirmOrder = () =>
|
||
replace(
|
||
originView(OrderConfirmationView, { orderId: "NVO-2048" }),
|
||
slideLeft,
|
||
);
|
||
```
|
||
|
||
The target inherits the origin's `previousNodeKey`, so a later back skips the
|
||
replaced entry. The origin remains mounted while the operation is interactive
|
||
or settling and is unmounted only after commit. Cancelling removes the proposed
|
||
target and restores the origin without changing history.
|
||
|
||
Without choreography, `replace(target)` creates an intent suitable for a
|
||
gesture builder:
|
||
|
||
```ts
|
||
.navigate(() => replace(originView(OrderConfirmationView)))
|
||
.animate(slideLeft)
|
||
```
|
||
|
||
`OriginNavigationActionOptions.placement` defaults to `"above"`.
|
||
|
||
### `originAction(target, choreography, options?)`
|
||
|
||
Constructs a complete low-level `OriginAction`. Prefer `forward()`, `replace()`,
|
||
and `back()` when expressing retained navigation.
|
||
|
||
```ts
|
||
const action = originAction(profile, slideLeft, {
|
||
placement: "above",
|
||
history: "push",
|
||
});
|
||
```
|
||
|
||
`OriginActionOptions`:
|
||
|
||
| Field | Type | Default | Description |
|
||
| ----------- | -------------------- | --------- | ---------------------------- |
|
||
| `placement` | `"above" \| "under"` | `"above"` | Target stacking relationship |
|
||
| `history` | `OriginHistoryMode` | `"push"` | Target history mutation |
|
||
|
||
### `above(target, choreography?, options?)`
|
||
|
||
Shorthand for `originAction()` with `placement: "above"`.
|
||
|
||
```ts
|
||
const openProfile = () => above(originView(ProfileView), slideLeft);
|
||
```
|
||
|
||
Placement controls stacking only. It does not imply a movement direction.
|
||
Omitting choreography returns a navigation intent for a gesture builder.
|
||
|
||
### `under(target, choreography?, options?)`
|
||
|
||
Shorthand for `originAction()` with `placement: "under"`.
|
||
|
||
```ts
|
||
const goBack = (context: OriginContext) =>
|
||
context.previous ? back(slideRight) : null;
|
||
```
|
||
|
||
`under()` does not automatically mean history back. It remains available for
|
||
custom stacking actions; `back()` is the clearer retained-history primitive.
|
||
Omitting choreography returns a navigation intent for a gesture builder.
|
||
|
||
### History modes
|
||
|
||
History is a linked chain of mounted scene nodes.
|
||
|
||
| Mode | Commit behavior |
|
||
| ----------- | -------------------------------------------------------------- |
|
||
| `"push"` | Park and retain the origin; activate the new target |
|
||
| `"replace"` | Create a new target, inherit prior history, unmount the origin |
|
||
| `"back"` | Reuse the retained previous target; pop and unmount the origin |
|
||
|
||
Parked entries are `inert`, `aria-hidden`, invisible, and excluded from pointer
|
||
input. They remain mounted until back pops them or the scene is destroyed.
|
||
|
||
## Gestures
|
||
|
||
### `gesture`
|
||
|
||
Immutable fluent builder for component-owned gesture policy:
|
||
|
||
```ts
|
||
const swipeBack = gesture.from
|
||
.left(32)
|
||
.to.right({ threshold: 10 })
|
||
.complete(({ progress, velocity }) => progress >= 0.4 || velocity >= 0.9)
|
||
.navigate((context) => (context.canGoBack ? back() : null))
|
||
.animate(slideRight);
|
||
```
|
||
|
||
The stages have distinct responsibilities:
|
||
|
||
| Stage | Responsibility |
|
||
| ---------------------- | ---------------------------------------------------------- |
|
||
| `.from.*` | Optional pointer-down eligibility |
|
||
| `.to.*` | Required movement direction and intent-recognition options |
|
||
| `.complete(predicate)` | Optional release commit/cancel decision |
|
||
| `.navigate(factory)` | Required target and retained-history intent |
|
||
| `.onCommit(handler)` | Optional side effect when a release commits |
|
||
| `.animate(routine)` | Required source/target/frame choreography |
|
||
|
||
The builder is persistent and immutable. Reusing an earlier stage cannot
|
||
change a definition already produced from it.
|
||
|
||
`.to.left()`, `.to.right()`, `.to.up()`, and `.to.down()` accept optional
|
||
`OriginGestureDirectionOptions`:
|
||
|
||
| Field | Default | Description |
|
||
| --------------- | ------- | ------------------------------------------------- |
|
||
| `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.
|
||
|
||
The completion context contains the origin, direction, normalized progress and
|
||
velocity, directed pixel distance, cross-axis distance, duration, pointer-up
|
||
event, host, bounds, and start/current points. Completion predicates are
|
||
synchronous because they select operation intent at release.
|
||
|
||
`.onCommit(handler)` is available at every stage after `.to.*`, returns the
|
||
same stage, and may be called more than once; handlers run in order. A handler
|
||
runs once, synchronously at release, after the operation commits and before
|
||
the settling animation, and receives the same context as `.complete()`. It
|
||
never runs for a cancelled release. A throwing handler is reported as an
|
||
uncaught error without stopping later handlers or the operation. It is the
|
||
place for haptic feedback:
|
||
|
||
```ts
|
||
const openProfile = gesture.to
|
||
.left()
|
||
.navigate(() => forward(originView(ProfileView)))
|
||
.onCommit(() => navigator.vibrate?.(10))
|
||
.animate(slideLeft);
|
||
```
|
||
|
||
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)`
|
||
|
||
Creates one primary-pointer, single-axis recognizer owned by the component that
|
||
calls it. `definition` may also be a ref or getter; a changed definition
|
||
applies from the next pointer-down.
|
||
|
||
```ts
|
||
const open = useOriginGesture(
|
||
gesture.to
|
||
.left({ threshold: 10 })
|
||
.navigate(() => forward(originView(DetailsView)))
|
||
.animate(slideLeft),
|
||
);
|
||
```
|
||
|
||
The return value contains:
|
||
|
||
```ts
|
||
interface OriginGestureBinding {
|
||
readonly style: Readonly<CSSProperties>;
|
||
readonly onPointerdown: (event: PointerEvent) => void;
|
||
readonly onPointermove: (event: PointerEvent) => void;
|
||
readonly onPointerup: (event: PointerEvent) => void;
|
||
readonly onPointercancel: () => void;
|
||
readonly onLostpointercapture: (event: PointerEvent) => void;
|
||
readonly listeners: Readonly<Record<string, (event: Event) => void>>;
|
||
readonly ref: (element: unknown) => void;
|
||
}
|
||
```
|
||
|
||
Apply the pointer handlers, the returned style, and `listeners` to the same
|
||
element, and pass `ref` as its function ref:
|
||
|
||
```vue
|
||
<div
|
||
:ref="binding.ref"
|
||
:style="binding.style"
|
||
v-bind="binding.listeners"
|
||
@pointerdown="binding.onPointerdown"
|
||
@pointermove="binding.onPointermove"
|
||
@pointerup="binding.onPointerup"
|
||
@pointercancel="binding.onPointercancel"
|
||
@lostpointercapture="binding.onLostpointercapture"
|
||
/>
|
||
```
|
||
|
||
The style sets dimensions and the host's `touch-action` (see
|
||
[Scrolling](#scrolling)). `listeners` is empty for ordinary gestures; for
|
||
scroll-edge gestures it tracks the scroller, and where the browser lacks
|
||
directional `touch-action` it adds a `touchmove` handler.
|
||
|
||
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 from the pointer-down point reaches `threshold`.
|
||
4. Directed movement exceeds cross-axis movement by `axisDominance`.
|
||
5. The navigation factory returns an intent.
|
||
|
||
Recognition is measured from where the pointer went down and keeps watching
|
||
until the pointer lifts, so a slightly wobbly or backward start only delays
|
||
it.
|
||
|
||
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.
|
||
|
||
A captured gesture is cancelled, never left mid-transition, when its pointer
|
||
ends without a pointer-up: on `pointercancel`, on `lostpointercapture`, when a
|
||
new primary pointer goes down, or when the component unmounts.
|
||
|
||
Failures are reported with `console.warn` and the gesture is abandoned: a
|
||
`navigate()` factory that throws or rejects, or a scene that rejects the
|
||
operation (see `OriginSceneError`). Returning `null` declines silently, as
|
||
does losing a race to another gesture on the same origin.
|
||
|
||
## Choreographies and effects
|
||
|
||
### `defineOriginChoreography(choreography)`
|
||
|
||
Type-safe identity helper for declaring custom visual routines.
|
||
|
||
```ts
|
||
const scaleIn = defineOriginChoreography({
|
||
name: "scale-in",
|
||
commitThreshold: 0.4,
|
||
commitVelocity: 0.8,
|
||
effects: ({ progress, viewport }) => ({
|
||
source: {
|
||
transform: `scale(${1 - progress * 0.08})`,
|
||
opacity: 1 - progress * 0.3,
|
||
},
|
||
target: {
|
||
transform: `translateY(${(1 - progress) * viewport.height}px)`,
|
||
},
|
||
}),
|
||
});
|
||
```
|
||
|
||
The function returns the same object. Its value is type checking and a clear
|
||
construction point.
|
||
|
||
Set `persistAtRest: true` when progress `1` should remain as a connected visual
|
||
relationship after a committed push:
|
||
|
||
```ts
|
||
const openPartialDrawer = defineOriginChoreography({
|
||
name: "partial-drawer-open",
|
||
persistAtRest: true,
|
||
effects: ({ progress }) => ({
|
||
source: {
|
||
transform: `translateX(${progress * 66.6667}%)`,
|
||
},
|
||
target: {
|
||
transform: `translateX(${(progress - 1) * 66.6667}%)`,
|
||
},
|
||
}),
|
||
});
|
||
```
|
||
|
||
This leaves the retained source mounted, visible, and inert instead of parking
|
||
it. The target remains the active history entry. Beginning back suspends the
|
||
resting relationship so a reciprocal close choreography can take over;
|
||
cancelling back restores it exactly.
|
||
|
||
Connected resting effects are supported only by retained-history push actions.
|
||
They are designed for partial drawers, inspectors, and other presentations
|
||
where both mounted views remain visible after commit. They do not appear in
|
||
`scene.operations`, which reports live interactive/settling edges only.
|
||
|
||
`effects()` may return:
|
||
|
||
| Effect | Applied to |
|
||
| -------- | ---------------------------------------------------------- |
|
||
| `frame` | Source, target, and descendants on both sides of this edge |
|
||
| `source` | Component that originated this operation |
|
||
| `target` | Component created by this operation and its descendants |
|
||
|
||
Transforms are concatenated from inherited frames to local frames. Opacity is
|
||
multiplied. Properties inside `style` use local-last precedence, except
|
||
`transform` and numeric `opacity`, which are also composed.
|
||
|
||
Choreography callbacks should be deterministic and free of side effects. They
|
||
can run repeatedly during rendering and animation.
|
||
|
||
### Commit thresholds
|
||
|
||
When `finish()` does not explicitly override the decision, the release velocity
|
||
decides first, then distance:
|
||
|
||
- 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.
|
||
|
||
The operation's intent becomes final before its spring settles.
|
||
|
||
### Included presets
|
||
|
||
| Export | Behavior |
|
||
| ------------ | ------------------------------------------------------------- |
|
||
| `slideLeft` | Target enters from the right above a slightly receding source |
|
||
| `slideRight` | Source exits right and reveals a target underneath |
|
||
| `fade` | Source fades out as target fades in |
|
||
|
||
These are ordinary `OriginChoreography` objects and can be replaced entirely.
|
||
|
||
### `normalizedEffect(effect, fallbackLayer?)`
|
||
|
||
Internal compositor helper exposed for custom diagnostics or compositors. It
|
||
returns a defined effect and adds `fallbackLayer` to the effect's own layer.
|
||
Applications normally return plain effects and let the scene normalize them.
|
||
|
||
## Node-scoped controls
|
||
|
||
### `useOrigin()`
|
||
|
||
Returns controls scoped to the scene node containing the calling component.
|
||
|
||
```ts
|
||
const origin = useOrigin();
|
||
|
||
await origin.perform(forward(originView(SettingsView), fade));
|
||
```
|
||
|
||
Return value:
|
||
|
||
| Field | Description |
|
||
| ----------------- | --------------------------------------------------- |
|
||
| `nodeKey` | Unique key of this mounted node |
|
||
| `scene` | Containing `OriginScene` |
|
||
| `context` | Reactive node-local `OriginContext` |
|
||
| `view` | Reactive shorthand for the current recipe |
|
||
| `previous` | Recipe belonging to the retained previous instance |
|
||
| `canGoBack` | Whether a retained previous instance exists |
|
||
| `begin(action)` | Create a target and return manual operation control |
|
||
| `perform(action)` | Create and programmatically commit a target |
|
||
|
||
The composable throws when called outside a view mounted by `OriginScene`.
|
||
There is no global `activeView`; the injected node containing the event is the
|
||
origin.
|
||
|
||
## Scene diagnostics and manual operations
|
||
|
||
### `OriginScene` fields
|
||
|
||
| Field | Type | Description |
|
||
| ------------ | ----------------------------------------- | ------------------------------------- |
|
||
| `nodes` | `ComputedRef<readonly OriginSceneNode[]>` | Currently mounted Vue component nodes |
|
||
| `operations` | `ComputedRef<readonly OriginOperation[]>` | Live operation edges |
|
||
| `roots` | `ShallowRef<readonly string[]>` | Visible operation-graph roots |
|
||
|
||
These fields are suitable for inspectors and diagnostics. Do not mutate their
|
||
contents.
|
||
|
||
### `scene.contextFor(nodeKey)`
|
||
|
||
Returns the `OriginContext` for a mounted node. Throws if the key no longer
|
||
exists.
|
||
|
||
### `scene.begin(originKey, action)`
|
||
|
||
For forward or replace, mounts a new target and waits one Vue tick for
|
||
measurement. For back, reveals and measures the retained previous node. It
|
||
then returns an `OriginOperationHandle`.
|
||
|
||
```ts
|
||
const handle = await scene.begin(nodeKey, action);
|
||
handle.update(0.25, 0.4);
|
||
const committed = await handle.finish();
|
||
```
|
||
|
||
Only one outgoing operation may exist for a given origin. Its created target
|
||
can immediately begin its own outgoing operation, enabling X → Y → Z chains.
|
||
|
||
### `OriginOperationHandle`
|
||
|
||
`intent` changes synchronously when `finish()` or `cancel()` is called, before
|
||
the settling spring runs.
|
||
|
||
Calling `update()` while an operation settles catches it: the spring stops and
|
||
progress is under control again, for example to grab a transition that is
|
||
still animating. A later `finish()` decides the outcome, and any earlier
|
||
`finish()` promise resolves with that final outcome.
|
||
|
||
| Member | Description |
|
||
| ----------------------------- | --------------------------------------------------- |
|
||
| `id` | Unique operation ID |
|
||
| `originKey` | Source node key |
|
||
| `targetKey` | Created or retained target node key |
|
||
| `update(progress, velocity?)` | Update normalized interactive state |
|
||
| `finish(options?)` | Decide, settle, and return whether target committed |
|
||
| `intent` | Selected outcome: `undecided`, `commit`, `cancel` |
|
||
| `cancel(options?)` | Force cancellation and remove the target branch |
|
||
|
||
`OriginFinishOptions`:
|
||
|
||
| Field | Default | Description |
|
||
| --------- | ------------------ | ---------------------------- |
|
||
| `commit` | threshold decision | Force commit or cancellation |
|
||
| `animate` | `true` | Run the settling spring |
|
||
|
||
### `scene.perform(originKey, action)`
|
||
|
||
Equivalent to beginning an operation and immediately finishing it with
|
||
`commit: true`. The target still uses the settling spring unless reduced motion
|
||
is active.
|
||
|
||
### Renderer integration methods
|
||
|
||
`registerElement()`, `registerContainer()`, `styleForNode()`, and
|
||
`isNodeInteractive()` are public at the TypeScript boundary because the Vue
|
||
renderer components consume them. They are internal integration APIs and may
|
||
change during the experimental series.
|
||
|
||
## Type reference
|
||
|
||
### `OriginView<Props>`
|
||
|
||
A component recipe containing `component`, optional `props`, optional `key`,
|
||
and optional diagnostic `name`.
|
||
|
||
### `OriginAction`
|
||
|
||
A choreography, placement, history mode, and—except for back—target recipe.
|
||
|
||
### `OriginNavigationIntent`
|
||
|
||
An animation-free target, placement, and history mutation returned by
|
||
`forward()`, `replace()`, `back()`, `above()`, or `under()` when choreography
|
||
is omitted. Gesture `.animate()` combines it with choreography to create the
|
||
internal action.
|
||
|
||
### Gesture definition types
|
||
|
||
- `OriginGestureDefinition`: immutable executable result passed to
|
||
`useOriginGesture()` or the `OriginGesture` component.
|
||
- `OriginGestureStart`: anywhere, edge, or predicate start policy.
|
||
- `OriginGestureDistance`: numeric CSS pixels or a CSS length string.
|
||
- `OriginGestureStartContext`: pointer-down event, origin, host, bounds, and
|
||
local/client point.
|
||
- `OriginGestureCompletionContext`: release metrics and origin/DOM context.
|
||
- `OriginGestureDirectionOptions`: `threshold` and `axisDominance`.
|
||
- `OriginGestureBinding`: host style and four pointer handlers.
|
||
- `OriginGestureSurfaceProps`: shared host tag and completed definition list.
|
||
- `MaybeOriginNavigationIntent`: synchronous or asynchronous nullable
|
||
navigation-factory result.
|
||
|
||
### `OriginContext`
|
||
|
||
Node-local action context:
|
||
|
||
- `nodeKey`: mounted origin identity.
|
||
- `view`: origin recipe.
|
||
- `canGoBack`: whether a retained previous instance exists.
|
||
- `previous`: recipe belonging to the mounted previous entry.
|
||
- `history`: recipes belonging to all retained previous entries.
|
||
|
||
### `OriginEffect`
|
||
|
||
| Field | Description |
|
||
| ----------- | ----------------------------------------------- |
|
||
| `transform` | Composable CSS transform contribution |
|
||
| `opacity` | Multiplicative opacity contribution |
|
||
| `layer` | Relative stacking contribution |
|
||
| `style` | Other CSS properties with local-last precedence |
|
||
|
||
`above()` adds a default target layer of `+1`; `under()` adds `-1`.
|
||
|
||
### `OriginChoreographyContext`
|
||
|
||
| Field | Description |
|
||
| ------------ | ------------------------------------------------------ |
|
||
| `progress` | Normalized `0..1` progress |
|
||
| `velocity` | Normalized progress units per second |
|
||
| `phase` | `preparing`, `interactive`, `settling`, or `finished` |
|
||
| `intent` | `undecided`, `commit`, or `cancel` |
|
||
| `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.
|
||
|
||
### Diagnostic types
|
||
|
||
- `OriginSceneNode`: mounted identity, retained previous key, state, recipe,
|
||
history, and incoming edge.
|
||
- `OriginSceneNodeState`: `active`, `transitioning`, `exposed`, or `parked`.
|
||
- `OriginOperation`: read-only live edge state.
|
||
- `OriginOperationPhase`: operation lifecycle phase.
|
||
- `OriginOperationIntent`: selected operation outcome.
|
||
- `OriginRect`: top, left, width, and height.
|
||
|
||
### Internal types
|
||
|
||
`OriginNodeScope` and `MutableOriginOperation` are renderer/runtime
|
||
implementation types. They are exported by the current barrel but marked
|
||
`@internal` and should not be application dependencies.
|
||
|
||
## Errors and constraints
|
||
|
||
Scene operations reject with `OriginSceneError`, whose `code` identifies the
|
||
cause:
|
||
|
||
| Code | Cause |
|
||
| ------------------- | --------------------------------------------------- |
|
||
| `missing-node` | The node no longer exists |
|
||
| `missing-origin` | The origin node does not exist |
|
||
| `inert-origin` | The origin is parked or exposed |
|
||
| `origin-busy` | The origin already has a live outgoing operation |
|
||
| `invalid-action` | The action cannot be executed as written |
|
||
| `no-previous-entry` | Back was requested without a retained entry |
|
||
| `undecided-forward` | Back was requested through an unreleased forward |
|
||
| `target-busy` | The back target is transitioning or still presented |
|
||
| `outside-scene` | `useOrigin()` was called outside an `<OriginScene>` |
|
||
|
||
- `useOrigin()` and `useOriginGesture()` must run inside a component mounted by
|
||
`OriginScene`.
|
||
- An origin can own only one outgoing operation at a time.
|
||
- Parked or exposed retained entries are inert and cannot originate operations.
|
||
The active connected target owns interactions until back reveals its source.
|
||
- A target can originate its own operation as soon as its incoming operation's
|
||
intent becomes commit.
|
||
- The included recognizer follows one primary pointer and one axis.
|
||
- Builder edges accept CSS lengths; arbitrary start policy belongs in
|
||
`.from.when()`.
|
||
- Every pushed history entry retains its Vue instance and DOM until a committed
|
||
back operation pops it. There is no eviction policy yet.
|
||
- Parked instances remain mounted, so their ordinary Vue effects and timers
|
||
continue running.
|
||
- A choreography creates one target. Chaining supports any number of
|
||
simultaneously mounted targets.
|
||
- Reduced-motion preference resolves settling immediately.
|
||
|
||
## Gestures in real apps
|
||
|
||
### Scrolling
|
||
|
||
Native scrolling stays in the browser's hands: gesture hosts never make it
|
||
wait for JavaScript. Each host gets a `touch-action` that keeps every pan no
|
||
gesture needs:
|
||
|
||
| Gestures on the host | `touch-action` |
|
||
| --------------------------------------------------------------- | ---------------- |
|
||
| Horizontal only | `pan-y` |
|
||
| Vertical only | `pan-x` |
|
||
| Both axes | `none` |
|
||
| `.from.scrollEdge(list, "top")` + `.to.down()`, list at the top | `pan-x pan-down` |
|
||
| The same, list scrolled | `manipulation` |
|
||
|
||
The browser therefore scrolls the free axis natively from the first frame,
|
||
and touches on a gesture's axis reach the recognizer untouched.
|
||
|
||
A gesture that shares its axis with a scroller, such as drag-down-to-dismiss
|
||
above a list, uses `.from.scrollEdge()`. While the list is at that edge, the
|
||
host reserves only the one pan the gesture needs: at the top, dragging down is
|
||
the gesture's and dragging up still scrolls the list natively. This is the
|
||
pull-to-refresh pattern from the Pointer Events specification, and it relies
|
||
on direction-specific `touch-action` values (`pan-down` and its siblings). The
|
||
host follows the list's scroll position through a passive scroll listener, so
|
||
the right value is in place before each touch starts.
|
||
|
||
Browsers without direction-specific values (Safari) fall back to deciding in
|
||
JavaScript: the host keeps the axis pannable and cancels native scrolling
|
||
from a `touchmove` handler for touches the gesture claims, at the browser's
|
||
first cancelable `touchmove` (after roughly 15–20px of movement). Blocking
|
||
touch handlers make the browser wait for JavaScript on every move, so this
|
||
fallback is used only for scroll-edge gestures and only where it is needed.
|
||
|
||
Nested scroll containers: browsers apply `touch-action` only up to the nearest
|
||
scroll container, so a list scrolling inside a horizontal gesture host would
|
||
otherwise let the browser take horizontal drags and cancel the gesture. Each
|
||
host publishes its value as the inherited `--nvo-touch-action` custom property,
|
||
and the package stylesheet applies it to descendants, so nested lists keep
|
||
`pan-y` too. This requires `@native-vue-router/core-v2/style.css`.
|
||
|
||
### 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. Ignored subtrees
|
||
also get `touch-action: auto` back from the package stylesheet, so a chip row
|
||
scrolls horizontally inside a horizontal gesture host.
|
||
|
||
### 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.
|