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.
Links and gesture components
NativeLink
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
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$routeinside a route component for that surface's scoped route. - Read
router.currentRouteonly 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 VueonActivated()oronDeactivated().<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'sinertandaria-hiddenboundary, 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:
- Confirm Vue 3.5+ and Vue Router 5, then install core and its CSS.
- Create the native runtime from the existing router and install it after
app.use(router). - Replace only the root visual outlet with
NativeRouterView; leave nested router views in route components intact. - Change navigation that needs native motion from
router.push()/RouterLinkto runtime methods orNativeLink. Keep ordinary Vue Router calls where no native transition is wanted. - Add
parentmetadata to deep-linkable child routes. - Identify peer routes such as tabs, assign
siblingOrder, choosesiblingHistory, and pass their locations toNativeNavigator. - Move active-screen side effects from unconditional mount hooks into native active or visible effects.
- Mark nested horizontal controls with
data-native-gesture="ignore"and add component gesture links only where the product intends them. - Choose cache policy per route and move durable state out of component-local memory.
- 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
- How the preview and commit model works
- Architecture reference
- Engineering constraints and trade-offs
- Scalability and extension points
- Platform integration
- Interactive demo
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.