Files
Native-Router-Vue/packages/core-v2/API.md
T
Claude 6889fc0112 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
2026-10-31 08:22:11 +00:00

40 KiB
Raw Blame History

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

The builder separates where a gesture begins from the direction it moves:

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:

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:

gesture.to.right();

This is semantically equivalent to:

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():

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.

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
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:

import "@native-vue-router/core-v2/style.css";

Create a scene:

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:

<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.

<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:

<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 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.

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:

<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.

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.

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:

const openProfile = () =>
  forward(originView(ProfileView, { userId: "42" }), slideLeft);

Without choreography, it creates an OriginNavigationIntent for a gesture builder:

.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:

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:

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:

.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.

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".

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".

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:

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:

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:

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.

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.

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.

const open = useOriginGesture(
  gesture.to
    .left({ threshold: 10 })
    .navigate(() => forward(originView(DetailsView)))
    .animate(slideLeft),
);

The return value contains:

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 onTouchmove: (event: TouchEvent) => void;
}

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. The first 6px of movement are along the gesture's axis and direction (see 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 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.

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:

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.

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.

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:

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

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:

    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.