---
title: "Mobile UI with Ionic Vue 8 · vuejs.school"
description: "Ionic gives a Capacitor app native-looking components, platform styles and a page stack. Build a small notes app with Ionic Vue 8 and run it on a phone."
canonical_url: "https://vuejs.school/community/blog/mobile-ui-with-ionic-vue-8"
meta:
  "og:description": "Ionic gives a Capacitor app native-looking components, platform styles and a page stack. Build a small notes app with Ionic Vue 8 and run it on a phone."
  "og:image": "https://vuejs.school/_og/d/a_~TW9iaWxlIFVJIHdpdGggSW9uaWMgVnVlIDggwrcgdnVlanMuc2Nob29s%2Cc_OgCard.takumi%2Ctitle_Mobile%2BUI%2Bwith%2BIonic%2BVue%2B8%2Cdescription_~SW9uaWMgZ2l2ZXMgYSBDYXBhY2l0b3IgYXBwIG5hdGl2ZS1sb29raW5nIGNvbXBvbmVudHMsIHBsYXRmb3JtIHN0eWxlcyBhbmQgYSBwYWdlIHN0YWNrLiBCdWlsZCBhIHNtYWxsIG5vdGVzIGFwcCB3aXRoIElvbmljIFZ1ZSA4IGFuZCBydW4gaXQgb24gYSBwaG9uZS4%2Ceyebrow_~VHV0b3JpYWwgwrcgNyBtaW4gcmVhZA%2Ctint_lilac%2Cp_Ii9jb21tdW5pdHkvYmxvZy9tb2JpbGUtdWktd2l0aC1pb25pYy12dWUtOCI%2Cs_7DpBBcqftpJKrrNi.png"
  "og:image:alt": "Mobile UI with Ionic Vue 8 · vuejs.school"
  "og:image:height": "600"
  "og:image:type": image/png
  "og:image:width": "1200"
  "og:title": "Mobile UI with Ionic Vue 8 · vuejs.school"
  "twitter:image": "https://vuejs.school/_og/d/a_~TW9iaWxlIFVJIHdpdGggSW9uaWMgVnVlIDggwrcgdnVlanMuc2Nob29s%2Cc_OgCard.takumi%2Ctitle_Mobile%2BUI%2Bwith%2BIonic%2BVue%2B8%2Cdescription_~SW9uaWMgZ2l2ZXMgYSBDYXBhY2l0b3IgYXBwIG5hdGl2ZS1sb29raW5nIGNvbXBvbmVudHMsIHBsYXRmb3JtIHN0eWxlcyBhbmQgYSBwYWdlIHN0YWNrLiBCdWlsZCBhIHNtYWxsIG5vdGVzIGFwcCB3aXRoIElvbmljIFZ1ZSA4IGFuZCBydW4gaXQgb24gYSBwaG9uZS4%2Ceyebrow_~VHV0b3JpYWwgwrcgNyBtaW4gcmVhZA%2Ctint_lilac%2Cp_Ii9jb21tdW5pdHkvYmxvZy9tb2JpbGUtdWktd2l0aC1pb25pYy12dWUtOCI%2Cs_7DpBBcqftpJKrrNi.png"
  "twitter:image:alt": "Mobile UI with Ionic Vue 8 · vuejs.school"
---

> Index of every page on this site: [llms.txt](https://vuejs.school/llms.txt)

## 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: `ios` on iPhones and iPads, and `md` (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 `createGesture` lets you build your own.
- **A navigation stack.** `ion-router-outlet` sits 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](https://ionicframework.com/docs/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 vue` creates the project in a `notes` folder from the `blank` template, which has a single page. The other Vue templates are `tabs`, `sidemenu` and `list`.
- For a Vue project, `ionic start` always integrates Capacitor: it installs the latest `@capacitor/core` and `@capacitor/cli`, currently 8.4, plus the App, Haptics, Keyboard and Status Bar plugins.
- `ionic serve` starts 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:

*src/main.ts*

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

*src/notes.ts*

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

*src/router/index.ts*

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

*src/views/NotesPage.vue*

```
<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-page` is the root of every routed page. The docs call it essential: transitions, base styling and the page lifecycle all depend on it.
- `ion-header` with an `ion-toolbar` is the bar at the top, and `ion-content` is the scrolling area.
- The second header, with `collapse="condense"`, gives iOS its large title, which shrinks into the toolbar as you scroll.
- `ion-list` and `ion-item` draw the rows. `router-link` works on any Ionic component, so a tap on a row opens the note.
- `onIonViewWillEnter` loads the notes. The next section explains why it is not `onMounted`.

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

*src/views/NotesPage.vue**Breaks***

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

*src/views/NotesPage.vue**Works***

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

*src/views/NoteDetailPage.vue*

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

*src/views/NotesPage.vue*

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

*src/views/NotesPage.vue*

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

*src/components/DarkModeToggle.vue*

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

**Check it stuck**3 questions from the Mobile UI with Ionic Vue quiz.

[**Practice now**](https://vuejs.school/learn/multiplatform/mobile-ui-with-ionic-vue?tab=quiz)

**Was this helpful?**

## **Keep reading**

[Tutorial · 7 min read**Tauri plugins, mobile builds and the updater**](https://vuejs.school/community/blog/tauri-plugins-mobile-builds-and-the-updater) [Tutorial · 6 min read**Desktop apps with Tauri 2 and Vue**](https://vuejs.school/community/blog/desktop-apps-with-tauri-2-and-vue)

```json
{"@context":"https://schema.org","@graph":[{"@id":"https://vuejs.school/#website","@type":"WebSite","description":"A free AI-instructed school for Vue.js. Short tutorials, runnable code and quizzes that adapt to you.","name":"vuejs.school","url":"https://vuejs.school/","publisher":{"@id":"https://vuejs.school/#identity"}},{"@id":"https://vuejs.school/community/blog/mobile-ui-with-ionic-vue-8#webpage","@type":"WebPage","dateModified":"2026-10-27T19:31:40.253Z","datePublished":"2026-06-16","description":"Ionic gives a Capacitor app native-looking components, platform styles and a page stack. Build a small notes app with Ionic Vue 8 and run it on a phone.","name":"Mobile UI with Ionic Vue 8","url":"https://vuejs.school/community/blog/mobile-ui-with-ionic-vue-8","breadcrumb":{"@id":"https://vuejs.school/community/blog/mobile-ui-with-ionic-vue-8#breadcrumb"},"isPartOf":{"@id":"https://vuejs.school/#website"},"potentialAction":[{"@type":"ReadAction","target":["https://vuejs.school/community/blog/mobile-ui-with-ionic-vue-8"]}],"primaryImageOfPage":{"@id":"https://vuejs.school/#logo"}},{"@id":"https://vuejs.school/#identity","@type":"Organization","description":"A free AI-instructed school for Vue.js. Short tutorials, runnable code and quizzes that adapt to you.","name":"vuejs.school","url":"https://vuejs.school","logo":{"@id":"https://vuejs.school/#logo"}},{"@id":"https://vuejs.school/community/blog/mobile-ui-with-ionic-vue-8#article","dateModified":"2026-10-27T19:31:40.253Z","datePublished":"2026-06-16","description":"Ionic gives a Capacitor app native-looking components, platform styles and a page stack. Build a small notes app with Ionic Vue 8 and run it on a phone.","headline":"Mobile UI with Ionic Vue 8","inLanguage":"en","isAccessibleForFree":true,"thumbnailUrl":"https://vuejs.school/_og/d/a_~TW9iaWxlIFVJIHdpdGggSW9uaWMgVnVlIDggwrcgdnVlanMuc2Nob29s%2Cc_OgCard.takumi%2Ctitle_Mobile%2BUI%2Bwith%2BIonic%2BVue%2B8%2Cdescription_~SW9uaWMgZ2l2ZXMgYSBDYXBhY2l0b3IgYXBwIG5hdGl2ZS1sb29raW5nIGNvbXBvbmVudHMsIHBsYXRmb3JtIHN0eWxlcyBhbmQgYSBwYWdlIHN0YWNrLiBCdWlsZCBhIHNtYWxsIG5vdGVzIGFwcCB3aXRoIElvbmljIFZ1ZSA4IGFuZCBydW4gaXQgb24gYSBwaG9uZS4%2Ceyebrow_~VHV0b3JpYWwgwrcgNyBtaW4gcmVhZA%2Ctint_lilac%2Cp_Ii9jb21tdW5pdHkvYmxvZy9tb2JpbGUtdWktd2l0aC1pb25pYy12dWUtOCI%2Cs_7DpBBcqftpJKrrNi.png","@type":["Article","BlogPosting"],"articleSection":["Tutorial"],"author":{"@id":"https://vuejs.school/#identity"},"image":{"@id":"https://vuejs.school/#/schema/image/1"},"isPartOf":{"@id":"https://vuejs.school/community/blog/mobile-ui-with-ionic-vue-8#webpage"},"mainEntityOfPage":{"@id":"https://vuejs.school/community/blog/mobile-ui-with-ionic-vue-8#webpage"},"publisher":{"@id":"https://vuejs.school/#identity"}},{"@id":"https://vuejs.school/community/blog/mobile-ui-with-ionic-vue-8#breadcrumb","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","name":"Blog","item":"https://vuejs.school/community/blog","position":1},{"@type":"ListItem","name":"Mobile UI with Ionic Vue 8","position":2}]},{"@id":"https://vuejs.school/#logo","@type":"ImageObject","caption":"vuejs.school","contentUrl":"https://vuejs.school/apple-touch-icon.png","inLanguage":"en","url":"https://vuejs.school/apple-touch-icon.png"},{"@id":"https://vuejs.school/#/schema/image/1","@type":"ImageObject","contentUrl":"https://vuejs.school/_og/d/a_~TW9iaWxlIFVJIHdpdGggSW9uaWMgVnVlIDggwrcgdnVlanMuc2Nob29s%2Cc_OgCard.takumi%2Ctitle_Mobile%2BUI%2Bwith%2BIonic%2BVue%2B8%2Cdescription_~SW9uaWMgZ2l2ZXMgYSBDYXBhY2l0b3IgYXBwIG5hdGl2ZS1sb29raW5nIGNvbXBvbmVudHMsIHBsYXRmb3JtIHN0eWxlcyBhbmQgYSBwYWdlIHN0YWNrLiBCdWlsZCBhIHNtYWxsIG5vdGVzIGFwcCB3aXRoIElvbmljIFZ1ZSA4IGFuZCBydW4gaXQgb24gYSBwaG9uZS4%2Ceyebrow_~VHV0b3JpYWwgwrcgNyBtaW4gcmVhZA%2Ctint_lilac%2Cp_Ii9jb21tdW5pdHkvYmxvZy9tb2JpbGUtdWktd2l0aC1pb25pYy12dWUtOCI%2Cs_7DpBBcqftpJKrrNi.png","inLanguage":"en","url":"https://vuejs.school/_og/d/a_~TW9iaWxlIFVJIHdpdGggSW9uaWMgVnVlIDggwrcgdnVlanMuc2Nob29s%2Cc_OgCard.takumi%2Ctitle_Mobile%2BUI%2Bwith%2BIonic%2BVue%2B8%2Cdescription_~SW9uaWMgZ2l2ZXMgYSBDYXBhY2l0b3IgYXBwIG5hdGl2ZS1sb29raW5nIGNvbXBvbmVudHMsIHBsYXRmb3JtIHN0eWxlcyBhbmQgYSBwYWdlIHN0YWNrLiBCdWlsZCBhIHNtYWxsIG5vdGVzIGFwcCB3aXRoIElvbmljIFZ1ZSA4IGFuZCBydW4gaXQgb24gYSBwaG9uZS4%2Ceyebrow_~VHV0b3JpYWwgwrcgNyBtaW4gcmVhZA%2Ctint_lilac%2Cp_Ii9jb21tdW5pdHkvYmxvZy9tb2JpbGUtdWktd2l0aC1pb25pYy12dWUtOCI%2Cs_7DpBBcqftpJKrrNi.png"}]}
```