Files
Native-Router-Vue/usage.md

28 KiB

Native Vue Router usage guide

Native Vue Router adds gesture-driven, interruptible navigation to Vue 3 while leaving Vue Router responsible for route matching, URLs, redirects, guards, and browser history. It renders a provisional destination beside the current route, lets a pointer gesture control the transition, and commits the Vue Router navigation only when the gesture completes.

The current support contract is Vue 3.5+, Vue Router 5, and client-side DOM rendering. SSR hydration of a live native view stack is not yet a complete feature.

Packages

Package Purpose
@native-vue-router/core Runtime, route surfaces, gestures, caching, lifecycle APIs, and profiler
@native-vue-router/preset-native Native-looking back and tab controls, safe-area CSS, and motion tokens
@native-vue-router/capacitor Hardware Back, deep links, app lifecycle, root exit, and haptics
@native-vue-router/electron Host back/forward integration and Chromium history-gesture suppression

Install

Install the core package alongside its peer dependencies:

npm install vue@^3.5 vue-router@^5 @native-vue-router/core

Add optional packages only when the application uses them:

npm install @native-vue-router/preset-native
npm install @native-vue-router/capacitor @capacitor/app @capacitor/core @capacitor/haptics
npm install @native-vue-router/electron

When consuming this repository directly, build the packages first and install the required package directories or packed tarballs into the target project:

npm install
npm run build:packages
npm pack --workspace @native-vue-router/core

Import the core stylesheet once from the application entry point. Import the preset stylesheet as well when using its controls:

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

The elements containing the navigator must have a definite height. A typical full-screen application uses:

html,
body,
#app {
  width: 100%;
  height: 100%;
  margin: 0;
}

Minimal setup

Create the Vue Router first, then create and install the native runtime. Install Vue Router before Native Vue Router so route injection and Options API $route scoping are configured correctly.

// src/main.ts
import { createApp } from "vue";
import { createRouter, createWebHistory } from "vue-router";
import { createNativeRouter } from "@native-vue-router/core";
import App from "./App.vue";
import HomeView from "./views/HomeView.vue";
import ProductView from "./views/ProductView.vue";
import "@native-vue-router/core/style.css";

export const router = createRouter({
  history: createWebHistory(),
  routes: [
    { path: "/", name: "home", component: HomeView },
    {
      path: "/products/:id",
      name: "product",
      component: ProductView,
      meta: {
        native: {
          presentation: "push",
          parent: "/",
          gesture: "edge",
        },
      },
    },
  ],
});

export const nativeRouter = createNativeRouter({
  router,
  cache: { maxInactive: 4 },
});

const app = createApp(App);
app.use(router);
app.use(nativeRouter);

await router.isReady();
app.mount("#app");

Replace the application-level <RouterView> with <NativeRouterView> and wrap the navigation surface in <NativeNavigator> to enable predictive Back.

<!-- src/App.vue -->
<script setup lang="ts">
import { NativeNavigator, NativeRouterView } from "@native-vue-router/core";
</script>

<template>
  <NativeNavigator>
    <NativeRouterView />
  </NativeNavigator>
</template>

Nested <RouterView> components inside route components continue to work. Use one application-level NativeRouterView for one native runtime; independent nested native navigators are not currently a complete feature.

Route metadata

Declare presentation, topology, history, cache, and gesture policy next to each route:

import type { RouteRecordRaw } from "vue-router";

export const routes: RouteRecordRaw[] = [
  {
    path: "/inbox",
    name: "inbox",
    component: () => import("./views/InboxView.vue"),
    meta: {
      native: {
        siblingGroup: "primary",
        siblingOrder: 0,
        siblingHistory: "replace",
        cache: "pin",
        gesture: "full",
      },
    },
  },
  {
    path: "/chat/:id",
    name: "chat",
    component: () => import("./views/ChatView.vue"),
    meta: {
      native: {
        presentation: "push",
        parent: "/inbox",
        gesture: "edge",
      },
    },
  },
  {
    path: "/chat/:id/details",
    name: "chat-details",
    component: () => import("./views/ChatDetailsView.vue"),
    meta: {
      native: {
        parent: (route) => ({
          name: "chat",
          params: { id: route.params.id },
          query: route.query,
        }),
      },
    },
  },
];
Option Meaning
presentation push, reveal, slide, fade, modal, sheet, none, or a registered custom name
transition Compatibility alias for presentation; prefer presentation in new code
parent Logical Back target for a cold-start/deep-linked route; may be a location or a function of the current route
siblingOrder Numeric visual order used to derive sibling direction
siblingHistory replace keeps peer selections out of Back history; push makes them Back destinations
cache false unmounts when inactive, true uses normal retention, and pin exempts the view from ordinary LRU trimming
gesture false disables navigator gestures for the route; edge and full describe intended policy
navigator, siblingGroup Reserved topology labels; the current navigator still receives its sibling list explicitly

Declare parent for detail, settings, modal, and other routes that should have a predictive destination when opened directly. It is a logical product relationship, not proof that a matching browser history entry exists.

Navigate

Use useNativeRouter() inside setup():

<script setup lang="ts">
import { useNativeRouter } from "@native-vue-router/core";

const native = useNativeRouter();

async function openProduct(id: string) {
  const committed = await native.push({
    name: "product",
    params: { id },
    query: { source: "featured" },
    hash: "#summary",
  });

  if (committed) {
    // Vue Router accepted the navigation.
  }
}
</script>

The same runtime is available as this.$nativeRouter in Options API components.

Method Use
push(to, options?) Add an animated history entry
replace(to, options?) Replace the current history entry
sibling(to, options?) Move between ordered peer routes
pop() Navigate to the previous native history entry or declared parent
present(to, presentation?) Present a route, using modal by default
dismiss() Return from a presented route
preload(to) Resolve and load a lazy route without mounting or committing it
unload(to) Unmount inactive instances of one location and return the count
trimCache(options?) Unmount inactive cached views while retaining lightweight descriptors

Navigation options can override presentation, replace, direction, and sourceRect. The navigation methods return true when Vue Router accepts the commit and false for a no-op, cancellation, or rejected navigation.

Use normal router.push() for redirects or flows that intentionally do not need a native preview. The runtime reconciles external Vue Router navigations, but they do not receive the same preview-driven transition.

NativeLink renders a real anchor, resolves its href, preserves modified-click behavior, and routes an ordinary primary click through the native runtime.

<script setup lang="ts">
import { NativeLink } from "@native-vue-router/core";
</script>

<template>
  <NativeLink
    :to="{ name: 'product', params: { id: '42' } }"
    presentation="push"
    class="product-link"
  >
    Product 42
  </NativeLink>
</template>

Its navigation props are to, replace, and presentation; other attributes are passed to the anchor.

NativeGestureLink lets a horizontal drag on a component reveal its destination. It accepts to, presentation (default reveal), replace, direction (left, right, or any), and as (default div).

<NativeGestureLink
  as="article"
  :to="{ name: 'product', params: { id: product.id } }"
  presentation="reveal"
  direction="left"
>
  <ProductCard :product="product" />
</NativeGestureLink>

Choose a semantic as element and provide keyboard behavior when the result is interactive. A normal click also invokes native push().

NativeNavigator

Pass ordered peer locations to enable full-surface horizontal sibling paging. The leading edge remains reserved for Back when canGoBack is true.

<NativeNavigator
  :siblings="['/inbox', '/stories', '/profile']"
  :edge-width="28"
>
  <NativeRouterView />
</NativeNavigator>

Inputs, editable content, links, buttons, and elements carrying data-native-gesture="ignore" are excluded from automatic gesture recognition. Use the explicit attribute for carousels, maps, editors, canvases, or other regions that own horizontal input.

NativeDismissGesture

Wrap a custom full-height modal surface to make a downward drag dismiss it:

<script setup lang="ts">
import { NativeDismissGesture, useNativeRouter } from "@native-vue-router/core";

const native = useNativeRouter();
</script>

<template>
  <NativeDismissGesture as="main" class="sheet">
    <button type="button" @click="native.dismiss()">Close</button>
    <!-- sheet content -->
  </NativeDismissGesture>
</template>

NativeSheet

Use NativeSheet inside a route whose presentation is sheet. It keeps the surface below the device's safe top inset, leaves the previous route visible but inert beneath a backdrop, and includes a drag handle.

With no breakpoints, the surface grows to its content and is capped at the available device height:

<script setup lang="ts">
import { NativeSheet } from "@native-vue-router/core";
</script>

<template>
  <NativeSheet aria-label="Filters">
    <FilterForm />
  </NativeSheet>
</template>

Supply fractional breakpoints to create snap points. Fractions are measured against the route height after the safe top inset has been reserved:

<script setup lang="ts">
import { ref } from "vue";
import { NativeSheet } from "@native-vue-router/core";

const breakpoint = ref(0.55);
</script>

<template>
  <NativeSheet
    v-model="breakpoint"
    :breakpoints="[0.3, 0.55, 1]"
    :initial-breakpoint="0.55"
    aria-label="Choose a location"
    @breakpoint-change="savePreferredSheetSize"
  >
    <LocationPicker />
  </NativeSheet>
</template>

Users drag the handle or use Arrow Up/Down, Home, and End while it is focused. Dragging below the smallest point dismisses the route. The backdrop and Escape also dismiss by default.

The scrollable sheet body participates in the same gesture automatically. At an interior scroll position, the content scrolls normally. When the content is at the top, pulling down hands the gesture to the sheet so it can move to a lower point or dismiss. When the content is at the bottom, pushing upward grows the sheet to its next point. Mouse/pen dragging, touch input, and thresholded trackpad/wheel overscroll follow the same boundary rules.

Gesture ownership is chosen from the initial directional intent and remains locked until release. A gesture that starts while content exists in that direction stays a content gesture even if it reaches an edge or reverses. Lift and begin a new gesture at the edge to resize the sheet. This prevents content and sheet movement from overlapping and prevents previously scrolled distance from becoming a sheet-height jump.

Prop Meaning
breakpoints Unique fractions greater than 0 and at most 1; an empty list enables content height
initialBreakpoint Initial fraction, snapped to the nearest declared point
modelValue Current fractional point for v-model
dismissible Enables sheet-triggered drag and keyboard dismissal; default true
backdropDismiss Lets a backdrop click dismiss; default true
showHandle Renders the built-in drag/keyboard handle; default true
ariaLabel Accessible dialog label; default Sheet

Use the handle slot to replace the visual handle without replacing its input behavior. Theme the surface with --nvr-sheet-background, --nvr-sheet-backdrop, --nvr-sheet-radius, and --nvr-sheet-top-gap.

For a route opened with native.present(to, "sheet"), the runtime remembers the sheet presentation after commit. Defining meta.native.presentation: "sheet" as well makes direct URL entry and raw Vue Router navigation use the same contained layout.

During presentation and dismissal, NativeSheet moves its actual surface by that surface's height rather than translating a transparent viewport-sized route wrapper. The source route remains continuously scaled as the sheet's underlay, avoiding a geometry jump when the route transaction commits.

Tabs and back controls

The optional native preset supplies a Back button and tab bar:

<script setup lang="ts">
import {
  NativeBackButton,
  NativeTabBar,
  type NativeTabItem,
} from "@native-vue-router/preset-native";

const tabs: NativeTabItem[] = [
  { label: "Inbox", to: "/inbox", icon: "◉" },
  { label: "Stories", to: "/stories", icon: "◎" },
  {
    label: "Profile",
    to: "/profile",
    icon: "◇",
    activeWhen: (route) => route.path.startsWith("/profile"),
  },
];
</script>

<template>
  <header><NativeBackButton label="Back" /></header>
  <NativeTabBar :items="tabs" />
</template>

The preset tab bar uses replace-style sibling navigation. Build a product-specific control with native.sibling() when tabs need different history semantics.

Route params, query, hash, and injected route state

Pass any normal RouteLocationRaw to native navigation methods and components. useRoute(), useRouter(), and Options API this.$route work within active and preview route trees. During a held gesture, the destination subtree sees its own params, query, hash, matched records, and metadata even though router.currentRoute still points at the committed source route.

This distinction is intentional:

  • Read useRoute() or $route inside a route component for that surface's scoped route.
  • Read router.currentRoute only when the application needs the globally committed route.
  • Expect the two values to differ while a preview is visible.

Normal Vue provide() and inject() work across the route surface. App-level provides, plugin provides, and values provided by route components remain available to descendants.

Lifecycle and cached views

Native Vue Router can keep inactive route component trees mounted. Vue's normal mount, update, and unmount hooks continue to describe component lifetime, but being mounted does not mean the route is the current screen.

Use the native lifecycle for route visibility and activity:

<script setup lang="ts">
import {
  onNativeViewActivate,
  onNativeViewDeactivate,
  onNativeViewEvict,
  useNativeViewActiveEffect,
  useNativeViewLifecycle,
  useNativeViewVisibleEffect,
} from "@native-vue-router/core";

const view = useNativeViewLifecycle();

useNativeViewActiveEffect(() => {
  const controller = new AbortController();
  startPolling({ signal: controller.signal });
  return () => controller.abort();
});

useNativeViewVisibleEffect(() => {
  const stop = startAnimationNeededDuringTransitions();
  return stop;
});

onNativeViewActivate(() => resumeMedia());
onNativeViewDeactivate(() => pauseMedia());
onNativeViewEvict((reason) => saveDraft(view.route.value, reason));
</script>

isActive means Vue Router has made the route authoritative. isVisible is also true for either side of an interactive transition and for a sheet's visual underlay. An underlay remains inert and is not active. isCached identifies a mounted inactive view; isPreview identifies an uncommitted destination.

Use active effects for polling, subscriptions, media, analytics, and work that should run only on the current route. Use visible effects for rendering work needed while the route is on screen during a transition. Put durable state in a store or persistence layer because cache eviction unmounts component-local state.

The default cache limit is four inactive, non-pinned views. History descriptors remain after a component tree is evicted and are remounted if navigation reaches them later.

Vue built-in components

NativeRouterView exposes Component, route, and entry through its default slot, so normal Vue wrappers can be used inside each native route layer:

<NativeRouterView v-slot="{ Component, route }">
  <Suspense>
    <Transition name="route-content" mode="out-in">
      <KeepAlive :max="3">
        <component :is="Component" :key="route.fullPath" />
      </KeepAlive>
    </Transition>

    <template #fallback>
      <RouteSkeleton />
    </template>
  </Suspense>
</NativeRouterView>

These components retain their normal Vue meaning:

  • <KeepAlive> caches components selected within that route layer. It is not a replacement for the native multi-route cache, and native route changes alone do not imply Vue onActivated() or onDeactivated().
  • <Transition> animates changes inside a layer. Native presentations animate the route layers themselves.
  • <Suspense> may show a fallback while an async preview component resolves.
  • <Teleport> can move DOM outside the layer. Because teleported DOM is outside the layer's inert and aria-hidden boundary, close or hide overlays whenever the owning native view is not visible.

A visibility-safe teleported overlay looks like this:

<script setup lang="ts">
import { ref } from "vue";
import { useNativeViewLifecycle } from "@native-vue-router/core";

const open = ref(false);
const view = useNativeViewLifecycle();
</script>

<template>
  <button type="button" @click="open = true">Open overlay</button>
  <Teleport to="body">
    <MyOverlay v-if="open && view.isVisible.value" @close="open = false" />
  </Teleport>
</template>

Options API lifecycle hooks (beforeCreate, created, beforeMount, mounted, beforeUpdate, updated, beforeUnmount, and unmounted) and the corresponding Composition API hooks keep their standard Vue behavior.

Guards, redirects, and preview side effects

Forward navigation resolves and loads the route component before commit so the user can drag a live destination. Vue Router guards run when the runtime commits the real push(), replace(), or Back operation. A rejected guard removes the preview and restores the source route; a redirect is reconciled to the route Vue Router accepts.

Consequently, a preview component may execute setup() and mount before a guard allows entry, then unmount without ever becoming active. Avoid irreversible work such as analytics events, mutations, purchases, or permanent subscriptions in unconditional setup/mount code. Tie committed-screen behavior to useNativeViewActiveEffect() or an explicit committed application action.

Custom presentations

Register a presentation at runtime or pass it in createNativeRouter():

import {
  createNativeRouter,
  definePresentation,
} from "@native-vue-router/core";

const scaleFade = definePresentation({
  name: "scale-fade",
  axis: "x",
  layerStyle({ role, progress }) {
    return role === "to"
      ? {
          opacity: progress,
          transform: `scale(${0.94 + progress * 0.06})`,
        }
      : { opacity: 1 - progress * 0.25 };
  },
});

const nativeRouter = createNativeRouter({
  router,
  presentations: [scaleFade],
});

// This is also valid later:
nativeRouter.registerPresentation(scaleFade);

Reference the registered name from route metadata or a navigation option. Presentation functions should derive compositor-friendly styles from progress; they must not mutate history or application state.

Advanced interactions can call beginInteractive(), updateInteractive(), finishInteractive(), and cancelInteractive() directly. Progress is normalized from 0 to 1, and velocity is normalized route progress per second. Cancel the transaction when the owning component unmounts and ignore stale async results by checking the returned transaction ID.

Platform adapters

Capacitor

import { createCapacitorAdapter } from "@native-vue-router/capacitor";

const nativeRouter = createNativeRouter({
  router,
  platform: createCapacitorAdapter({
    haptics: true,
    exitAtRoot: true,
    trimCacheOnPause: true,
    deepLinkPath: (url) => `${url.pathname}${url.search}${url.hash}`,
  }),
});

The adapter handles hardware Back, launch/app URLs, pause cancellation, cache trimming, optional haptics, and optional exit at the root. Configure Universal Links/App Links in the native project separately.

Electron

Disable Chromium's competing overscroll navigation in the main process before app.whenReady():

import { app } from "electron";
import { disableElectronHistoryGestures } from "@native-vue-router/electron";

disableElectronHistoryGestures(app.commandLine);

Install the renderer adapter after exposing the documented window.nativeVueHost back/forward/memory-pressure bridge from a secure preload:

import { createElectronRendererAdapter } from "@native-vue-router/electron";

const nativeRouter = createNativeRouter({
  router,
  platform: createElectronRendererAdapter(),
});

Use hash history for packaged file: applications unless the Electron host serves navigation URLs through an application protocol.

Browser and PWA

The core works in normal browser tabs, but a browser may reserve an edge gesture before page JavaScript can claim it. An installed iOS PWA can improve gesture ownership with an early non-passive edge guard, but web content cannot change WKWebView.allowsBackForwardNavigationGestures. Use Capacitor when deterministic native-level ownership is required.

Add to an existing Vue Router application

Adopt the library incrementally:

  1. Confirm Vue 3.5+ and Vue Router 5, then install core and its CSS.
  2. Create the native runtime from the existing router and install it after app.use(router).
  3. Replace only the root visual outlet with NativeRouterView; leave nested router views in route components intact.
  4. Change navigation that needs native motion from router.push()/RouterLink to runtime methods or NativeLink. Keep ordinary Vue Router calls where no native transition is wanted.
  5. Add parent metadata to deep-linkable child routes.
  6. Identify peer routes such as tabs, assign siblingOrder, choose siblingHistory, and pass their locations to NativeNavigator.
  7. Move active-screen side effects from unconditional mount hooks into native active or visible effects.
  8. Mark nested horizontal controls with data-native-gesture="ignore" and add component gesture links only where the product intends them.
  9. Choose cache policy per route and move durable state out of component-local memory.
  10. Exercise URLs, redirects, rejected guards, direct deep links, browser Back and Forward, held/cancelled gestures, reduced motion, and keyboard/focus behavior before broad rollout.

For a lower-risk migration, start with button-driven push(), pop(), and present(). Add predictive Back, siblings, and component-originated gestures after the route topology and lifecycle behavior are verified.

Diagnostics and profiling

Inspect the runtime's reactive state while integrating:

const native = useNativeRouter();

watchEffect(() => {
  console.table(native.cacheStats.value);
  console.log(native.transaction.value);
});

const stop = native.onDiagnostic((event) => {
  console.debug("native-navigation", event);
});

The opt-in profiler records frame cadence and timing-safe navigation events:

import { createNativeNavigationProfiler } from "@native-vue-router/core";

const profiler = createNativeNavigationProfiler(nativeRouter, {
  metadata: { build: import.meta.env.VITE_BUILD_ID },
});

profiler.start();
// Reproduce the navigation.
const report = profiler.stop();
const json = profiler.toJSON(report);
profiler.dispose();

Route params, query values, and application state are omitted from profiler route labels.

Troubleshooting

The route surface is blank or has zero height. Give html, body, #app, and the application shell a definite height, and import the core stylesheet.

useNativeRouter() says the plugin is not installed. Create one runtime and call app.use(nativeRouter) before mounting the app.

useNativeViewLifecycle() throws. Call it only from a component rendered inside NativeRouterView.

A route mounts even though a guard rejects it. This is preview behavior, not a committed navigation. Move irreversible work to an active effect.

Back has no visual destination after a direct deep link. Add a parent location or parent function to that route's native metadata.

A tab appears in browser Back history. Set siblingHistory: "replace" and use native.sibling() or the preset tab bar.

A carousel or editor fights the route gesture. Put data-native-gesture="ignore" on the region that owns the input.

A teleported dialog remains interactive from an inactive cached route. Gate the teleport content on useNativeViewLifecycle().isVisible and close it on hide/deactivate when appropriate.

Local state disappears. The cache is bounded and can be trimmed by a host. Use cache: "pin" sparingly or store durable state outside the route component.

Further reading

AI agent skill

This repository includes a portable integration skill at skills/integrate-native-vue-router. Copy that complete directory into the skills location recognized by the agent (for Codex, normally ~/.codex/skills/) and invoke it as $integrate-native-vue-router. Keep SKILL.md, agents/openai.yaml, and the references directory together so the integration workflow retains its API and verification reference.