DesktopShell
The desktop app frame. DesktopShell arranges an app's SidebarRail 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,
SidebarRail,
SidebarRailItem,
Sidebar,
SidebarItem,
SidebarLabel,
PageHeader,
Button,
SidebarHeader,
Avatar,
} 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',
]
// Long enough to overflow the content area: the point of the story is that
// only this region scrolls while the rail, sidebar and header 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'],
['Spacing scale: 4px or 8px base?', 'Sam Rivera', 19, '2w'],
['Archive: 2023 brand refresh', 'Ana Costa', 2, '3w'],
] as [string, string, number, string][]
).map(([title, author, replies, time]) => ({
title,
author,
replies,
time,
}))
</script>
<template>
<!-- Bounded height so the shell's `h-full` has something to fill in the preview. -->
<div
class="h-[560px] overflow-hidden rounded-5 border w-full bg-surface-white"
>
<DesktopShell>
<template #rail>
<SidebarRail class="border-r">
<SidebarRailItem
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">
<SidebarRailItem
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>
</SidebarRailItem>
</div>
<SidebarRailItem label="Search" variant="ghost" icon="lucide-search" />
</SidebarRail>
</template>
<template #sidebar>
<Sidebar class="border-r" :collapsible="false" width="14rem">
<SidebarHeader
title="Acme Design"
subtitle="v1.0.0-beta"
:menu-items="[{ label: 'Log out', icon: 'lucide-log-out' }]"
/>
<div class="flex-1 overflow-y-auto space-y-0.5 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="divide-y divide-outline-gray-1">
<div
v-for="post in posts"
:key="post.title"
class="flex items-center gap-3 px-5 py-3 hover:bg-surface-gray-1"
>
<Avatar :label="post.author" size="lg" />
<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>
</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 owns its scroll region and hands it to everything it renders, so useShellScrolled() resolves it with no wiring — a page can scroll to the top, or a router scrollBehavior can read the scroll offset, without the app owning a global.
import { shellScrollContainer, useShellScrolled } from 'frappe-ui'
const scrolled = useShellScrolled({ threshold: 12 })
shellScrollContainer.value?.scrollTo({ top: 0, behavior: 'smooth' })A page inside the shell reads that shell, even while another is still mounted. shellScrollContainer is the fallback for what a page cannot reach that way: a router scrollBehavior, a navigation guard, anything outside a component. It is a stack, so swapping DesktopShell for MobileShell on a viewport change hands the active container over cleanly whatever the mount order.
Turning the page scroll off
:scroll="false" makes the content area fill the remaining height and never page-scroll. Reach for it in a multi-pane layout where the panes own their own overflow — a list-and-detail split, or a board whose columns scroll separately. Without it an app fakes the same thing with absolute inset-0, a hardcoded h-[calc(100vh-3rem)], or [&>div]:h-full.
<DesktopShell :scroll="false">
<template #sidebar><Sidebar /></template>
<div class="flex min-h-0 flex-1">
<ScrollArea class="w-72 border-e"><ItemList /></ScrollArea>
<ScrollArea class="flex-1"><Detail /></ScrollArea>
</div>
</DesktopShell>With scroll="false" there is no shell scroll element, so shellScrollContainer is null and useShellScrolled() stays false. Read the pane's own ScrollArea instead.
Slot names
DesktopShell has #rail and #sidebar; MobileShell has #nav. The names describe regions, not components: the desktop frame has two side regions that can appear together, and the mobile frame has one bar along the bottom. One shared name would have to mean "the icon column", "the navigation panel" and "the tab bar" at once, and an app rendering both a rail and a sidebar could not say which is which.
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
export interface DesktopShellProps {
/**
* Whether the content area scrolls as one page. Defaults to `true`.
*
* Pass `false` for a multi-pane layout where the panes own their own
* overflow — a list-and-detail split, or a board whose columns scroll
* separately. The content area then fills the remaining height and never
* page-scrolls, instead of the app faking it with `absolute inset-0` or a
* hardcoded `h-[calc(100vh-3rem)]`.
*
* `false` also means there is no shell scroll element, so
* `shellScrollContainer` stays `null` and `useShellScrolled()` stays `false`.
*/
scroll?: boolean
}Whether the content area scrolls as one page. Defaults to `true`. Pass `false` for a multi-pane layout where the panes own their own overflow — a list-and-detail split, or a board whose columns scroll separately. The content area then fills the remaining height and never page-scrolls, instead of the app faking it with `absolute inset-0` or a hardcoded `h-[calc(100vh-3rem)]`. `false` also means there is no shell scroll element, so `shellScrollContainer` stays `null` and `useShellScrolled()` stays `false`.
| Slot | Payload |
|---|---|
rail | — The icon column — usually a `SidebarRail`. Omit it on shells that don't use one. |
sidebar | — The navigation panel — usually a `Sidebar`. Render it conditionally to hide it on routes that don't need it. |
default | — The routed page content, placed in the scroll region (or the fixed-height pane when `scroll` is `false`). |
The icon column — usually a `SidebarRail`. Omit it on shells that don't use one.
The navigation panel — usually a `Sidebar`. Render it conditionally to hide it on routes that don't need it.
The routed page content, placed in the scroll region (or the fixed-height pane when `scroll` is `false`).