Frappe UIFrappe UI

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.

Spaces

Website
P
Design review: new onboarding flow
Priya Nair · 14 replies
2h
S
Consolidate icon sizes to a 4px grid
Sam Rivera · 32 replies
5h
A
Dark mode tokens are ready for review
Ana Costa · 21 replies
1d
L
Hero copy for the marketing site
Liam Fischer · 5 replies
1d
M
Illustration style guide, first draft
Mei Tanaka · 11 replies
2d
N
Accessibility audit findings
Noah Berg · 27 replies
4d
I
Retiring the old button variants
Ines Duarte · 9 replies
5d
T
Naming conventions for new components
Tomas Ruiz · 16 replies
1w
Z
Figma library cleanup
Zara Ahmed · 4 replies
1w
P
Empty states we still need to design
Priya Nair · 6 replies
2w
S
Spacing scale: 4px or 8px base?
Sam Rivera · 19 replies
2w
A
Archive: 2023 brand refresh
Ana Costa · 2 replies
3w
vue
<script setup lang="ts">
import { ref } from 'vue'
import {
  DesktopShell,
  Rail,
  RailItem,
  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.
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'],
].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-md border w-full bg-surface-white"
  >
    <DesktopShell>
      <template #rail>
        <Rail class="border-r">
          <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 class="border-r" disable-collapse 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 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
typescript
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
export interface DesktopShellProps {}
scroll
= true
boolean

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.

rail
{}
sidebar
{}
default
{}