MobileShell
The mobile app frame. MobileShell is a fixed, full-height column: a pinned PageHeaderTarget on top (with safe-area padding for installed PWAs), a native-scrolling content area, and a #nav slot for a bottom MobileNav. It's a separate family from DesktopShell — mobile and desktop are different navigation models, so the app chooses which to render for the viewport rather than toggling one responsive component.
<script setup lang="ts">
import { ref } from 'vue'
import {
MobileShell,
MobileNav,
MobileNavItem,
PageHeaderMobile,
Avatar,
} from 'frappe-ui'
const tab = ref('home')
// Long enough to overflow the content area: the point of the story is that
// only this region scrolls while the header and tab bar stay put.
// Typed as a tuple so the destructured `author` is a string, not `string | number`.
const posts = (
[
['Design review: new onboarding flow', 'Priya Nair', 14, '2h'],
['Consolidate icon sizes to a 4px grid', 'Sam Rivera', 32, '5h'],
['Dark mode tokens are ready for review', 'Ana Costa', 21, '1d'],
['Hero copy for the marketing site', 'Liam Fischer', 5, '1d'],
['Illustration style guide, first draft', 'Mei Tanaka', 11, '2d'],
['Accessibility audit findings', 'Noah Berg', 27, '4d'],
['Retiring the old button variants', 'Ines Duarte', 9, '5d'],
['Naming conventions for new components', 'Tomas Ruiz', 16, '1w'],
['Figma library cleanup', 'Zara Ahmed', 4, '1w'],
['Empty states we still need to design', 'Priya Nair', 6, '2w'],
] as [string, string, number, string][]
).map(([title, author, replies, time]) => ({
title,
author,
replies,
time,
}))
</script>
<template>
<!--
MobileShell is `fixed inset-0`. The `transform` on this wrapper makes it the
containing block for that fixed positioning, so the shell stays inside the
preview box instead of covering the page. (A story-only concern.)
-->
<div
class="mx-auto h-[600px] w-[360px] transform-gpu overflow-hidden border bg-surface-white"
>
<MobileShell>
<PageHeaderMobile>
<span class="text-base font-semibold text-ink-gray-8">Home</span>
</PageHeaderMobile>
<div class="divide-y divide-outline-gray-1">
<div
v-for="post in posts"
:key="post.title"
class="flex items-center gap-3 px-4 py-3 active:bg-surface-gray-1"
>
<Avatar :label="post.author" size="md" />
<div class="min-w-0 flex-1">
<div class="truncate text-base-medium text-ink-gray-8">
{{ post.title }}
</div>
<div class="mt-1 truncate text-sm text-ink-gray-5">
{{ post.author }} · {{ post.replies }} replies
</div>
</div>
<span class="shrink-0 text-sm text-ink-gray-4">{{ post.time }}</span>
</div>
</div>
<template #nav>
<MobileNav>
<MobileNavItem
label="Home"
icon="lucide-house"
:active="tab === 'home'"
@click="tab = 'home'"
/>
<MobileNavItem
label="Notifications"
icon="lucide-bell"
:active="tab === 'notifications'"
@click="tab = 'notifications'"
/>
<MobileNavItem
label="Search"
icon="lucide-search"
:active="tab === 'search'"
@click="tab = 'search'"
/>
<MobileNavItem
label="You"
:active="tab === 'you'"
@click="tab = 'you'"
>
<template #default="{ active }">
<Avatar
label="Jane Doe"
size="md"
:class="active ? 'ring-2 ring-outline-gray-4' : ''"
/>
</template>
</MobileNavItem>
</MobileNav>
</template>
</MobileShell>
</div>
</template>Put the routed page in the default slot and the tab bar in #nav. The content area scrolls with the platform's own momentum and overscroll; the header and nav stay fixed to the edges.
Scroll container
Like DesktopShell, the content area is handed to everything the shell renders, and registered into the shared registry, so shellScrollContainer and useShellScrolled({ threshold }) resolve it — a tapped-active tab or a router scrollBehavior can drive it with no app-owned global.
Slot names
MobileShell has #nav; DesktopShell has #rail and #sidebar. The names describe regions, not components. See DesktopShell for why they differ.
API Reference
| Slot | Payload |
|---|---|
default | — The routed page content, placed in the native-scrolling content area. |
nav | — The bottom navigation — usually a `MobileNav`. |
The routed page content, placed in the native-scrolling content area.
The bottom navigation — usually a `MobileNav`.