Capacitor Native Navigation is a plugin for Capacitor that allows a React DOM application to use native UI for views, stacks and tabs.
The traditional method of using React DOM on native is to either mimic native navigation transitions or to simply behave like a webapp without transitions and without a native backstack. Capacitor Native Navigation lets you use all of the native navigation containers from React DOM, often transparently, so you have the best of native and web.
To install Capacitor Native Navigation in your app we add the required packages to your app... these are the packages usually required for a React app using ReactRouter:
pnpm add capacitor-native-navigation capacitor-native-navigation-react capacitor-native-navigation-react-router
To use your local development server from your app, we need to setup some package scripts and tweak the Capacitor configuration like we have done in our example app:
In package.json
add the following scripts:
{
"scripts": {
"cap:local": "CAP_SERVER=http://$(ipconfig getifaddr en0 || ipconfig getifaddr en1):5173/ cap sync",
"start:host": "vite --host $(ipconfig getifaddr en0 || ipconfig getifaddr en1)",
}
}
In your capacitor.config.ts
, add to your server
configuration:
import process from 'process'
const config: CapacitorConfig = {
...,
server: {
/* Set the CAP_SERVER environment variable when running cap copy or cap sync; see the cap:local npm script */
url: process.env.CAP_SERVER || undefined,
},
}
If you see an error on the process
module import and your project is a Vite project, you may need to add capacitor.config.ts
to the list of files to include in tsconfig.node.json
.
To use, now run:
pnpm cap:local
pnpm start:host
Then build and run the native app. Changes you make should automatically reload in your app.
Native applications are made up of "views". Views can be organised in "stacks" or "tabs". Views can also be presented modally, over another view. On iOS the native component representing a view is the UIViewController
.
A stack is a collection of views with the top-most view being the visible one, and the views underneath it forming a back-stack. On iOS the native component representing a stack is the UINavigationController
, on Android the built-in back button navigates back through stacks.
Tabs are another collection of views with buttons (tabs) to switch between the different views. On iOS the native component representing tabs
is the UITabBarController
.
The Capacitor Native Navigation plugin exposes an API for manipulating native views from JavaScript. You'll use the API to initialise the application, but most of the navigation in the application itself can avoid using the Capacitor Native Navigation API by taking advantage of routing library integration. This is a goal of Capacitor Native Navigation, to not require tight coupling in your application.
The first thing you'll want to do is to present a native view. The present
API call includes:
- a
component
specification, which describes a structure or tabs, stacks and views to present. - a presentation
style
, in case you're presenting modally. - an
animated
flag, to allow or prevent transition animations.
Here is an example to present a new stack containing a single view:
const stack = await NativeNavigation.present({
component: {
type: 'stack',
stack: [
{
type: 'view',
path: '/welcome',
options: {
title: 'Welcome,
}
}
],
},
animated: false,
})
The result of the present
API call is an object containing the id
of the presented component. You can also specify the id
in the component specification to hard-code it.
Modal views can often be dismissed by the user themselves using native controls, but all presented views can be dismissed using the dismiss
API. The dismiss
API call includes:
- the component
id
to dismiss. - an
animated
flag, to allow or prevent transition animations.
When you have an existing stack, you can push a new view onto it, or replace the top-most view. The push
API call includes:
- a
component
specification, which describes a structure or tabs, stacks and views to push. - the
target
component id, to identify the stack to push to. This is usually omitted, meaning push to the current stack. - an
animated
flag, to allow or prevent transition animations. - the
mode
, either push (the default), or replace or root (to reset the stack back to just the new component).
NativeNavigation.push({
component: {
type: 'view',
path: '/features',
},
})
Usually the user will pop views themselves using native back controls, but you can also trigger popping one or more views off a stack. The pop
API call includes:
- a
stack
id, to identify the stack to pop from. This is usually omitted, meaning pop from the current stack. - a
count
of the number of views to pop, which defaults to 1. - an
animated
flag, to allow or prevent transition animations.
NativeNavigation.pop({})
Capacitor Native Navigation integrates with React to render React components for each view or screen in the app. Each view has a path (and search, hash and state), which is used to work out which components to show; often using a routing library such as React Router (see below).
The React integration is activated by calling initReact
and passing a reference to the NativeNavigation
plugin, and the root component that will render each view.
import { NativeNavigation } from 'capacitor-native-navigation'
import { initReact, NativeNavigationReactRootProps } from 'capacitor-native-navigation-react'
function Root(props: NativeNavigationReactRootProps): JSX.Element {
const { pathname, search, hash, state } = props
...
}
initReact({
plugin: NativeNavigation,
root: Root,
})
capacitor-native-navigation-react
Capacitor Native Navigation tries as much as possible to be a seamless adaptation of React DOM to native, however there are some differences that you should be aware of.
Each view is mounted as a separate React portal. Views in a stack remain mounted, even when not the frontmost in the stack, so they continue to respond to state changes (such as Redux, or timers), even if they're not currently visible. Be careful not to trigger unintentional side-effects such as navigation from a component that is not visible.
Capacitor Native Navigation transparently integrates with React Router so that the navigate()
function
translates pushes, replaces and backs into their native equivalent. This enables Capacitor Native Navigation to be very loosely coupled
with your app; you start with a separate native entrypoint, but then reuse all of you web routing and navigating (navigate
, Link
, etc)
code.
The root view component receives all of the location information from Capacitor Native Navigation in its props. We use Router
from react-router-dom
to create the root router with a custom Navigator
.
import { Route, Router, Routes } from 'react-router-dom'
import { NativeNavigation } from 'capacitor-native-navigation'
import { NativeNavigationReactRootProps } from 'capacitor-native-navigation-react'
import { useNativeNavigationNavigator } from 'capacitor-native-navigation-react-router'
export default function Root(props: NativeNavigationReactRootProps): JSX.Element {
const { pathname, search, hash, state } = props
const navigator = useNativeNavigationNavigator({
plugin: NativeNavigation,
modals: [],
})
return (
<Router location={{ pathname, search, hash, state }} navigator={navigator}>
<Routes>
...
</Routes>
</Router>
)
}
capacitor-native-navigation-react-router
Special support is available for modal views in the useNativeNavigationNavigator
hook.
const navigator = useNativeNavigationNavigator({
plugin: NativeNavigation,
modals: [
{
/* The path prefix for views that should be in the modal */
path: '/modal/',
/* A function to return the component specification for the view to present for the modal */
presentOptions(path, state) {
return {
component: {
type: 'stack',
stack: [
{
type: 'view',
path,
state,
options: {
/* We can specify the title here, or set it using `update` from the component */
title: 'My Modal Title',
stack: {
rightItems: [
/* Add a close button to the view */
{
id: 'close',
title: 'Close',
},
],
},
},
},
],
},
style: 'formSheet',
cancellable: false
}
},
},
]
})
When developing for mobile, it’s crucial to ensure that your content respects the safe area insets, which prevent content from overlapping with native elements like the navigation bar, home indicator, and toolbars. This plugin provides CSS variables that help manage these safe areas effectively.
The plugin injects CSS variables based on the safe area insets and toolbar height, allowing you to control margins and padding to avoid overlap with native UI components. These values adapt dynamically based on the device’s safe areas.
Here’s a breakdown of the CSS variables injected by the plugin:
At a minimum, your app should align its content with the native-navigation-inset
values. Doing so ensures that your content is positioned correctly below the toolbar (if it’s visible) or beneath the status bar (if the toolbar is hidden).
-
Navigation Insets: A simplified safe area inset that accomodates the toolbar if present
--native-navigation-inset-top
--native-navigation-inset-bottom
--native-navigation-inset-left
--native-navigation-inset-right
-
Safe Content Insets: The insets where it is both safe for gesutres and safe for drawing (ignoring toolbar appearance)
--native-navigation-safe-content-inset-top
--native-navigation-safe-content-inset-bottom
--native-navigation-safe-content-inset-left
--native-navigation-safe-content-inset-right
-
Safe Drawing Insets: The insets where it is both safe for drawing (ignoring toolbar appearance)
--native-navigation-safe-drawing-inset-top
--native-navigation-safe-drawing-inset-bottom
--native-navigation-safe-drawing-inset-left
--native-navigation-safe-drawing-inset-right
-
Safe Gestures Insets: The insets where it is both safe for gestures (ignoring toolbar appearance)
--native-navigation-safe-gestures-inset-top
--native-navigation-safe-gestures-inset-bottom
--native-navigation-safe-gestures-inset-left
--native-navigation-safe-gestures-inset-right
-
Toolbar Height
--native-navigation-toolbar-height
: The height of the toolbar if present, useful for additional margin adjustments.
To apply these values, you can use CSS calc()
to set padding or margins based on the device’s safe area. Here’s an example for setting padding on the body
element:
body {
padding-top: calc(var(--native-navigation-inset-top, env(safe-area-inset-top, 0)));
padding-bottom: calc(var(--native-navigation-inset-bottom, env(safe-area-inset-bottom, 0)));
padding-left: calc(var(--native-navigation-inset-left, env(safe-area-inset-left, 0)));
padding-right: calc(var(--native-navigation-inset-right, env(safe-area-inset-right, 0)));
}
- Android tabs support
To build all of the packages:
nvm use
pnpm install
pnpm build
This repository uses changesets
. A changeset file should be included with most commits:
pnpm changeset
Include the file generated in ./changeset
in your commit.
Build and run the Example app to try it out.
Create global pnpm links for the current node version for the packages in this repository:
pnpm run link
Then in your app (adjust the package list to match the packages you have installed):
pnpm link --global capacitor-native-navigation capacitor-native-navigation-react capacitor-native-navigation-react-router
Remember this will break every time you run pnpm install
, so to make it semi-permanent change the package.json
to use
link:./path/to/capacitor-native-navigation/packages/plugin
etc.
To publish a new version to npm we process the changesets files and then build, test, git tag and publish to npmjs.com.
nvm use
pnpm install
pnpm release:version
Commit the changed files, and then:
pnpm release