DesktopShell
The desktop app frame. DesktopShell arranges an app's Rail and Sidebar alongside the main content, and owns the skeleton the content needs: a pinned PageHeaderTarget and a registered scroll region. Its mobile counterpart is a separate family — MobileShell — because the two are different navigation models, not one responsive component.
<script setup lang="ts">
import { ref } from 'vue'
import {
DesktopShell,
Rail,
RailItem,
Sidebar,
SidebarItem,
SidebarLabel,
PageHeader,
Button,
} from 'frappe-ui'
const community = ref('design')
const communities = [
{ id: 'design', initials: 'DE' },
{ id: 'engineering', initials: 'EN' },
{ id: 'marketing', initials: 'MK' },
]
const space = ref('Website')
const spaces = [
'Website',
'Brand',
'Illustration',
'Design System',
'Marketing Site',
'Mobile App',
'Research',
'Archive',
]
</script>
<template>
<!-- Bounded height so the shell's `h-full` has something to fill in the preview. -->
<div class="h-[560px] overflow-hidden rounded-md border bg-surface-white">
<DesktopShell>
<template #rail>
<Rail>
<RailItem
label="Home"
variant="ghost"
icon="lucide-house"
:active="community === ''"
@click="community = ''"
/>
<div class="flex w-full flex-1 flex-col items-center gap-3 pt-3">
<RailItem
v-for="c in communities"
:key="c.id"
:label="c.id"
:active="community === c.id"
@click="community = c.id"
>
<span class="text-2xs-medium uppercase text-ink-gray-5">{{ c.initials }}</span>
</RailItem>
</div>
<RailItem label="Search" variant="ghost" icon="lucide-search" />
</Rail>
</template>
<template #sidebar>
<Sidebar disable-collapse width="14rem">
<div class="px-3 py-2 text-base font-medium text-ink-gray-8">Acme Design</div>
<div class="flex-1 overflow-y-auto px-2 pb-2">
<SidebarLabel class="px-2">Spaces</SidebarLabel>
<SidebarItem
v-for="s in spaces"
:key="s"
:active="s === space"
@click="space = s"
>
<template #prefix><span class="lucide-hash size-4" aria-hidden="true" /></template>
{{ s }}
</SidebarItem>
</div>
</Sidebar>
</template>
<!-- Declared in the page; teleports to the target the shell pins on top. -->
<PageHeader>
<span class="text-lg font-semibold text-ink-gray-8">{{ space }}</span>
<Button variant="subtle" icon-left="lucide-plus" label="New post" />
</PageHeader>
<div class="p-5">
<p
v-for="n in 40"
:key="n"
class="mb-3 text-base text-ink-gray-7"
>
Row {{ n }} — scroll the content; the header stays pinned above it.
</p>
</div>
</DesktopShell>
</div>
</template>Put the icon column in #rail, the navigation panel in #sidebar (render it conditionally to hide it on routes that don't need it), and the routed page in the default slot. Pages declare their own PageHeader anywhere — it teleports to the target the shell pins above the scroll region.
Scroll container
The shell registers its scroll region into a small module registry, so useScrollContainer() and the plain getScrollContainer() resolve it with no wiring — a page can scroll-to-top or a router scrollBehavior can read the scroll offset without the app owning a global. Because the registry is a stack, swapping DesktopShell for MobileShell on a viewport change hands the active container over cleanly.
Theming the content region
The content region exposes data-slot="desktop-shell-content" for app-level styling. Target that slot in CSS when an app needs a card, gutter, border, background, or other product-specific surface treatment.
API Reference
Show types
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
export interface DesktopShellProps {}Whether the content area scrolls as one page (default). Set `false` for multi-pane layouts where inner panes own their own scroll — the content area then fills the remaining height and never page-scrolls.
| Slot | Payload |
|---|---|
rail | {} |
sidebar | {} |
default | {} |