Capacitor turns any built Vue app into an iOS and Android app. What it does not give you is an interface that feels native: a title that shrinks as you scroll on iOS, a swipe back to the previous screen, a list that looks like the Settings app, a sheet that slides up from the bottom. That is Ionic's job.
This tutorial builds a small notes app with @ionic/vue 8.8 and Capacitor 8.4, the current releases, and runs it on a phone. If you are still deciding whether your app needs Ionic at all, start with the last section.
What Ionic adds on top of Capacitor
Ionic Framework is a library of UI components built as web components (custom HTML elements that work in any framework), and @ionic/vue wraps them for Vue. Over a plain Capacitor app it adds four things:
- Native-looking components. Toolbars, lists, tabs, toggles, modals and action sheets that match what people see in other apps.
- Platform styles. Ionic has two modes:
ioson iPhones and iPads, andmd(Material Design) on Android and everywhere else. One template, and each phone gets its own look. The<html>element carries the mode as a class, so you can override styles per platform. - Gestures. Swipe back on iOS and swipe to close a sheet work out of the box, and
createGesturelets you build your own. - A navigation stack.
ion-router-outletsits on top of Vue Router and keeps earlier pages alive while it animates new ones in, the way a native app does.
Capacitor stays the bridge to the device: camera, files, the app store build. The Ionic Vue docs name it the official runtime.
Create the project
The Ionic Vue quickstart uses the Ionic CLI:
npm install -g @ionic/cli
ionic start notes blank --type vue
cd notes
ionic serve
ionic start notes blank --type vuecreates the project in anotesfolder from theblanktemplate, which has a single page. The other Vue templates aretabs,sidemenuandlist.- For a Vue project,
ionic startalways integrates Capacitor: it installs the latest@capacitor/coreand@capacitor/cli, currently 8.4, plus the App, Haptics, Keyboard and Status Bar plugins. ionic servestarts the Vite dev server and opens the browser, where you will build most of the app.
The starter is TypeScript with <script setup lang="ts">, and its src/main.ts installs the IonicVue plugin and Ionic's CSS. Shortened, it looks like this:
import { createApp } from 'vue'
import { IonicVue } from '@ionic/vue'
import App from './App.vue'
import router from './router'
import '@ionic/vue/css/core.css'
import '@ionic/vue/css/normalize.css'
import '@ionic/vue/css/structure.css'
import '@ionic/vue/css/typography.css'
import '@ionic/vue/css/palettes/dark.system.css'
import './theme/variables.css'
const app = createApp(App).use(IonicVue).use(router)
router.isReady().then(() => {
app.mount('#app')
})
core.css is required; without it the components lose the base styles they depend on. The app mounts only once the router has resolved the first route.
Set up the routes and the list page
The notes live in localStorage, behind two small functions:
export interface Note {
id: number
title: string
}
export function loadNotes(): Note[] {
return JSON.parse(localStorage.getItem('notes') ?? '[]') as Note[]
}
export function saveNotes(notes: Note[]) {
localStorage.setItem('notes', JSON.stringify(notes))
}
The router file is normal Vue Router with one change: createRouter and createWebHistory come from @ionic/vue-router. That import is what connects routing to Ionic's outlet and its page transitions. props: true passes the :id from the URL to the page as a prop, which the Ionic docs recommend over reading the route inside the component.
import { createRouter, createWebHistory } from '@ionic/vue-router'
import type { RouteRecordRaw } from 'vue-router'
import NotesPage from '../views/NotesPage.vue'
const routes: RouteRecordRaw[] = [
{ path: '/', redirect: '/notes' },
{ path: '/notes', component: NotesPage },
{ path: '/notes/:id', component: () => import('../views/NoteDetailPage.vue'), props: true },
]
export default createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes,
})
src/App.vue from the starter already renders <ion-app> with a single <ion-router-outlet /> inside, so the list page is next:
<script setup lang="ts">
import { ref } from 'vue'
import { IonContent, IonHeader, IonItem, IonLabel, IonList, IonPage, IonTitle, IonToolbar, onIonViewWillEnter } from '@ionic/vue'
import { loadNotes, type Note } from '../notes'
const notes = ref<Note[]>([])
onIonViewWillEnter(() => {
notes.value = loadNotes()
})
</script>
<template>
<ion-page>
<ion-header :translucent="true">
<ion-toolbar>
<ion-title>Notes</ion-title>
</ion-toolbar>
</ion-header>
<ion-content :fullscreen="true">
<ion-header collapse="condense">
<ion-toolbar>
<ion-title size="large">Notes</ion-title>
</ion-toolbar>
</ion-header>
<ion-list>
<ion-item v-for="note in notes" :key="note.id" :router-link="`/notes/${note.id}`">
<ion-label>{{ note.title }}</ion-label>
</ion-item>
</ion-list>
</ion-content>
</ion-page>
</template>
What each part does:
ion-pageis the root of every routed page. The docs call it essential: transitions, base styling and the page lifecycle all depend on it.ion-headerwith anion-toolbaris the bar at the top, andion-contentis the scrolling area.- The second header, with
collapse="condense", gives iOS its large title, which shrinks into the toolbar as you scroll. ion-listandion-itemdraw the rows.router-linkworks on any Ionic component, so a tap on a row opens the note.onIonViewWillEnterloads the notes. The next section explains why it is notonMounted.
Reload data when a page comes back into view
When you navigate inside ion-router-outlet, Ionic keeps the old page in the DOM, hidden, and slides the new one over it. Going back is instant, and the old page still has its scroll position. A page is only destroyed when it is popped off the stack, for example by the back button.
That changes when Vue's lifecycle hooks run. onMounted fires the first time a page is shown, and not again when you come back to it. A list loaded there goes stale:
onMounted(() => {
notes.value = loadNotes()
})
Delete a note on the detail page, go back, and the deleted note is still in the list, because the list page never left the DOM. Ionic adds four hooks for this (onIonViewWillEnter, onIonViewDidEnter, onIonViewWillLeave and onIonViewDidLeave), and onIonViewWillEnter runs every time the page is about to appear:
onIonViewWillEnter(() => {
notes.value = loadNotes()
})
For the same reason, the docs say not to use <keep-alive>, <transition> or <router-view> in an Ionic Vue app. The outlet already does their work.
Delete with an action sheet, add with a modal
The detail page offers a delete action in an action sheet, the menu that slides up from the bottom. actionSheetController creates one from code, and the data of the button you tap comes back from onDidDismiss(). useIonRouter navigates with Ionic's transitions, so back() plays the back animation.
<script setup lang="ts">
import { IonBackButton, IonButton, IonButtons, IonHeader, IonPage, IonTitle, IonToolbar, actionSheetController, useIonRouter } from '@ionic/vue'
import { loadNotes, saveNotes } from '../notes'
const props = defineProps<{ id: string }>()
const ionRouter = useIonRouter()
const note = loadNotes().find(n => n.id === Number(props.id))
async function showActions() {
const sheet = await actionSheetController.create({
header: note?.title,
buttons: [
{ text: 'Delete', role: 'destructive', data: { action: 'delete' } },
{ text: 'Cancel', role: 'cancel' },
],
})
await sheet.present()
const { data } = await sheet.onDidDismiss()
if (data?.action === 'delete') {
saveNotes(loadNotes().filter(n => n.id !== note?.id))
ionRouter.back()
}
}
</script>
<template>
<ion-page>
<ion-header>
<ion-toolbar>
<ion-buttons slot="start"><ion-back-button default-href="/notes" /></ion-buttons>
<ion-title>{{ note?.title }}</ion-title>
<ion-buttons slot="end"><ion-button @click="showActions">More</ion-button></ion-buttons>
</ion-toolbar>
</ion-header>
</ion-page>
</template>
The cancel button always sits at the bottom, and a tap on the backdrop runs the cancel button's handler. Neither returns any data, so the note stays. ion-back-button goes back through the stack, or to default-href when someone opens the note from a link and there is no history.
For a modal, the docs recommend writing ion-modal inline. Add this inside the list page's ion-content, after the list, and put a "New" button in the top toolbar, built like the detail page's More button, that sets open to true:
<ion-modal :is-open="open" @didDismiss="open = false">
<ion-header>
<ion-toolbar>
<ion-title>New note</ion-title>
<ion-buttons slot="end"><ion-button :strong="true" @click="add">Save</ion-button></ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding">
<ion-input v-model="title" label="Title" label-placement="stacked" />
</ion-content>
</ion-modal>
The page's script needs IonButton, IonButtons, IonInput and IonModal added to its @ionic/vue import, saveNotes added to its ../notes import, and this code:
const open = ref(false)
const title = ref('')
function add() {
notes.value = [...notes.value, { id: Date.now(), title: title.value }]
saveNotes(notes.value)
title.value = ''
open.value = false
}
add() puts the new note into notes itself. The modal opens over the page without navigating, so onIonViewWillEnter does not run when it closes. is-open is one-way: Ionic does not set your ref back to false when the modal closes some other way, such as the Escape key or Android's back button, which is why @didDismiss does it.
Dark mode and safe areas
The starter imports dark.system.css, which follows the phone's setting. For a toggle, import dark.class.css in main.ts instead; it applies the dark palette when the ion-palette-dark class is on the <html> element:
<script setup lang="ts">
import { ref, watchEffect } from 'vue'
import { IonItem, IonToggle } from '@ionic/vue'
const dark = ref(window.matchMedia('(prefers-color-scheme: dark)').matches)
watchEffect(() => {
document.documentElement.classList.toggle('ion-palette-dark', dark.value)
})
</script>
<template>
<ion-item>
<ion-toggle v-model="dark">Dark mode</ion-toggle>
</ion-item>
</template>
The toggle starts from the phone's own setting, read once with matchMedia. The second argument of classList.toggle adds the class when dark is true and removes it when it is false, and watchEffect runs that line again on every flip.
Safe areas are already handled. Capacitor 8 apps target Android 16 (API level 36), so on Android 15 and later they draw edge to edge, under the system bars. On Android, Capacitor's built-in System Bars plugin also writes the inset sizes into --safe-area-inset-* CSS variables. Ionic 8.8's core.css reads those variables, with env(safe-area-inset-*) as the fallback, so headers and tab bars clear the status bar and the notch without the padding rules you would write by hand in a plain Vue app.
Run it on a phone
Capacitor 8 needs Node 22 or newer, Xcode 26 on a Mac for iOS, and Android Studio Otter for Android. Build the web app, add the native projects and run with live reload:
ionic build
ionic cap add android
ionic cap add ios
ionic cap run android -l --external
-l starts the dev server and points the app at it, and --external serves it on your network so a real phone can reach your computer. Save a file and the phone updates. The android and ios folders are real native projects: commit them, and open them with ionic cap open ios when you need Xcode for signing or permissions.
When a plain responsive Vue app is enough
Ionic earns its place in an app that should feel at home on a phone: a stack of screens, tabs, swipe back, bottom sheets. It also brings a second system to learn next to Vue, with its own components, CSS variables and page lifecycle.
Skip it when your users mostly reach you in a browser, when your app is a set of forms and content pages, or when you have your own design that should look identical on both platforms. Platform styles work against that last case. A responsive Vue app, wrapped with Capacitor if it needs to be in the stores, is less to carry.
Decide at the start. Adding Ionic later means wrapping every page in ion-page and moving every onMounted that loads data.