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.

Acme Design

Spaces

Website

Row 1 — scroll the content; the header stays pinned above it.

Row 2 — scroll the content; the header stays pinned above it.

Row 3 — scroll the content; the header stays pinned above it.

Row 4 — scroll the content; the header stays pinned above it.

Row 5 — scroll the content; the header stays pinned above it.

Row 6 — scroll the content; the header stays pinned above it.

Row 7 — scroll the content; the header stays pinned above it.

Row 8 — scroll the content; the header stays pinned above it.

Row 9 — scroll the content; the header stays pinned above it.

Row 10 — scroll the content; the header stays pinned above it.

Row 11 — scroll the content; the header stays pinned above it.

Row 12 — scroll the content; the header stays pinned above it.

Row 13 — scroll the content; the header stays pinned above it.

Row 14 — scroll the content; the header stays pinned above it.

Row 15 — scroll the content; the header stays pinned above it.

Row 16 — scroll the content; the header stays pinned above it.

Row 17 — scroll the content; the header stays pinned above it.

Row 18 — scroll the content; the header stays pinned above it.

Row 19 — scroll the content; the header stays pinned above it.

Row 20 — scroll the content; the header stays pinned above it.

Row 21 — scroll the content; the header stays pinned above it.

Row 22 — scroll the content; the header stays pinned above it.

Row 23 — scroll the content; the header stays pinned above it.

Row 24 — scroll the content; the header stays pinned above it.

Row 25 — scroll the content; the header stays pinned above it.

Row 26 — scroll the content; the header stays pinned above it.

Row 27 — scroll the content; the header stays pinned above it.

Row 28 — scroll the content; the header stays pinned above it.

Row 29 — scroll the content; the header stays pinned above it.

Row 30 — scroll the content; the header stays pinned above it.

Row 31 — scroll the content; the header stays pinned above it.

Row 32 — scroll the content; the header stays pinned above it.

Row 33 — scroll the content; the header stays pinned above it.

Row 34 — scroll the content; the header stays pinned above it.

Row 35 — scroll the content; the header stays pinned above it.

Row 36 — scroll the content; the header stays pinned above it.

Row 37 — scroll the content; the header stays pinned above it.

Row 38 — scroll the content; the header stays pinned above it.

Row 39 — scroll the content; the header stays pinned above it.

Row 40 — scroll the content; the header stays pinned above it.

vue
<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
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
{}