suiss/ui
  • Docs
  • Components
  • Patterns
  • Interactions
  • Charts
  • Blocks
GitHubGet started
DocsComponentsPatternsInteractionsChartsBlocks
Patterns19
  • Overview
  • Action Bar
  • Action Menu
  • App Shell
  • Command Palette
  • Confirm
  • Context Actions
  • Date Picker
  • Flyout
  • Modal
  • Navbar
  • Paged List
  • Panel
  • Picker
  • Preview
  • Search Picker
  • Segment Picker
  • Split View
  • Table List
  • Trail

suiss/ui · Pattern

App Shell

An application frame with a collapsible sidebar on desktop and a floating tab bar on mobile.

App Shell is an open-source pattern from suiss/ui for React. The @esuiss/ui CLI copies its source into your project, so you own the code.

import {
  CalendarBlankIcon,
  GearIcon,
  HouseIcon,
  TrayIcon,
} from "@phosphor-icons/react"
import * as React from "react"
import {
  AppShell,
  type AppShellItem,
  AppShellTrigger,
} from "@/components/patterns/app-shell"

const items: AppShellItem[] = [
  {
    value: "home",
    label: "Home",
    icon: (
      <HouseIcon />
    ),
  },
  {
    value: "inbox",
    label: "Inbox",
    icon: (
      <TrayIcon />
    ),
    badge: 12,
  },
  {
    value: "calendar",
    label: "Calendar",
    icon: (
      <CalendarBlankIcon />
    ),
  },
  {
    value: "settings",
    label: "Settings",
    icon: (
      <GearIcon />
    ),
  },
]

function Page({ title }: { title: string }) {
  return (
    <>
      <header className="flex h-14 shrink-0 items-center gap-2 border-b border-border px-4">
        <AppShellTrigger className="-ms-1" />
        <h1 className="text-base font-semibold">{title}</h1>
      </header>
      <div className="flex flex-1 flex-col gap-4 p-4">
        <div className="grid grid-cols-3 gap-3 md:gap-4">
          {["Revenue", "Active users", "Open issues"].map((label) => (
            <div
              key={label}
              className="flex h-20 flex-col justify-end rounded-2xl bg-muted p-3 md:aspect-video md:h-auto md:p-4"
            >
              <span className="text-sm text-muted-foreground">{label}</span>
            </div>
          ))}
        </div>
        <div className="min-h-64 flex-1 rounded-2xl bg-muted" />
      </div>
    </>
  )
}

function ShellExample(props: React.ComponentProps<typeof AppShell>) {
  const [value, setValue] = React.useState(props.defaultValue ?? "home")
  const title = props.items.find((item) => item.value === value)?.label ?? ""

  return (
    <AppShell {...props} value={value} onValueChange={setValue}>
      <Page title={title} />
    </AppShell>
  )
}

export default function AppShellDemo() {
  return (
    <div className="min-h-svh">
      <ShellExample items={items} />
    </div>
  )
}

AppShell builds navigation from an items array of AppShellItem objects and wraps your page content. On desktop it renders a Sidebar inside a SidebarProvider, with optional header and footer, group labels, badges, tooltips on collapsed icons and a SidebarRail; the page renders in a SidebarInset.

Below the 48rem breakpoint the sidebar is replaced by a TabBar pinned to the bottom of the screen, and the content renders in a main element padded to clear it. Items that do not fit in the tab bar move behind its More trigger. Use it as the outermost layout of an app with a handful of top-level destinations.

Installation

CLI

$ pnpm dlx shadcn@latest add @esuiss/app-shell

Works in any project with a components.json. See Installation for the one-line registry setup. Or use the suiss/ui CLI:

$ pnpm dlx @esuiss/ui@latest add patterns app-shell

Manual

  1. Add the cn helper.

  2. Add the pieces it builds on: Sidebar, Tab Bar.

  3. Copy the source into components/patterns/app-shell.tsx:

components/patterns/app-shell.tsx
"use client"

import * as React from "react"
import {
  Sidebar,
  SidebarContent,
  SidebarFooter,
  SidebarGroup,
  SidebarGroupContent,
  SidebarGroupLabel,
  SidebarHeader,
  SidebarInset,
  SidebarMenu,
  SidebarMenuBadge,
  SidebarMenuButton,
  SidebarMenuItem,
  1. Update the import paths to match your project.

Usage

import { AppShell, AppShellTrigger } from "@/components/patterns/app-shell"
<ShellExample items={items} />

Each AppShellItem has a value, label and icon, plus optional href, badge, group and disabled. Items with the same group render under a shared SidebarGroupLabel. The selected item is controlled with value and onValueChange, or uncontrolled with defaultValue; it defaults to the first item. Items with an href render through render, which defaults to a plain anchor, so you can pass your router's link component.

The sidebar accepts variant (sidebar, floating or inset), collapsible (icon by default, offcanvas or none), side, rail, and open, defaultOpen and onOpenChange for its expanded state. moreLabel sets the tab bar's More trigger text and label names the navigation landmark (Main by default). Place AppShellTrigger in your page header to toggle the sidebar.

Examples

With header and footer

Adds a brand menu in the sidebar header and an account menu in the footer.

import {
  CalendarBlankIcon,
  GearIcon,
  HouseIcon,
  TrayIcon,
} from "@phosphor-icons/react"
import * as React from "react"
import {
  AppShell,
  type AppShellItem,
  AppShellTrigger,
} from "@/components/patterns/app-shell"
import {
  SidebarMenu,
  SidebarMenuButton,
  SidebarMenuItem,
} from "@/components/ui/sidebar"

const items: AppShellItem[] = [
  {
    value: "home",
    label: "Home",
    icon: (
      <HouseIcon />
    ),
  },
  {
    value: "inbox",
    label: "Inbox",
    icon: (
      <TrayIcon />
    ),
    badge: 12,
  },
  {
    value: "calendar",
    label: "Calendar",
    icon: (
      <CalendarBlankIcon />
    ),
  },
  {
    value: "settings",
    label: "Settings",
    icon: (
      <GearIcon />
    ),
  },
]

const brand = (
  <SidebarMenu>
    <SidebarMenuItem>
      <SidebarMenuButton size="lg">
        <div className="flex size-8 shrink-0 items-center justify-center rounded-xl bg-primary text-sm font-semibold text-primary-foreground">
          A
        </div>
        <span className="truncate font-medium">Acme Inc.</span>
      </SidebarMenuButton>
    </SidebarMenuItem>
  </SidebarMenu>
)

const account = (
  <SidebarMenu>
    <SidebarMenuItem>
      <SidebarMenuButton size="lg">
        <div className="flex size-8 shrink-0 items-center justify-center rounded-full bg-secondary text-xs font-medium">
          JL
        </div>
        <div className="grid flex-1 text-start leading-tight">
          <span className="truncate text-sm font-medium">Jordan Lee</span>
          <span className="truncate text-xs text-muted-foreground">
            [email protected]
          </span>
        </div>
      </SidebarMenuButton>
    </SidebarMenuItem>
  </SidebarMenu>
)

function Page({ title }: { title: string }) {
  return (
    <>
      <header className="flex h-14 shrink-0 items-center gap-2 border-b border-border px-4">
        <AppShellTrigger className="-ms-1" />
        <h1 className="text-base font-semibold">{title}</h1>
      </header>
      <div className="flex flex-1 flex-col gap-4 p-4">
        <div className="grid grid-cols-3 gap-3 md:gap-4">
          {["Revenue", "Active users", "Open issues"].map((label) => (
            <div
              key={label}
              className="flex h-20 flex-col justify-end rounded-2xl bg-muted p-3 md:aspect-video md:h-auto md:p-4"
            >
              <span className="text-sm text-muted-foreground">{label}</span>
            </div>
          ))}
        </div>
        <div className="min-h-64 flex-1 rounded-2xl bg-muted" />
      </div>
    </>
  )
}

function ShellExample(props: React.ComponentProps<typeof AppShell>) {
  const [value, setValue] = React.useState(props.defaultValue ?? "home")
  const title = props.items.find((item) => item.value === value)?.label ?? ""

  return (
    <AppShell {...props} value={value} onValueChange={setValue}>
      <Page title={title} />
    </AppShell>
  )
}

export default function AppShellWithHeaderAndFooter() {
  return (
    <div className="min-h-svh">
      <ShellExample items={items} header={brand} footer={account} />
    </div>
  )
}

Grouped

Splits items into labelled Platform and Workspace groups and disables one item.

import {
  CalendarBlankIcon,
  ChartBarIcon,
  FolderIcon,
  GearIcon,
  HouseIcon,
  LifebuoyIcon,
  TrayIcon,
  UsersIcon,
} from "@phosphor-icons/react"
import * as React from "react"
import {
  AppShell,
  type AppShellItem,
  AppShellTrigger,
} from "@/components/patterns/app-shell"
import {
  SidebarMenu,
  SidebarMenuButton,
  SidebarMenuItem,
} from "@/components/ui/sidebar"

const manyItems: AppShellItem[] = [
  {
    value: "home",
    label: "Home",
    icon: (
      <HouseIcon />
    ),
    group: "Platform",
  },
  {
    value: "inbox",
    label: "Inbox",
    icon: (
      <TrayIcon />
    ),
    badge: 12,
    group: "Platform",
  },
  {
    value: "calendar",
    label: "Calendar",
    icon: (
      <CalendarBlankIcon />
    ),
    group: "Platform",
  },
  {
    value: "reports",
    label: "Reports",
    icon: (
      <ChartBarIcon />
    ),
    group: "Platform",
  },
  {
    value: "projects",
    label: "Projects",
    icon: (
      <FolderIcon />
    ),
    group: "Platform",
  },
  {
    value: "team",
    label: "Team",
    icon: (
      <UsersIcon />
    ),
    group: "Workspace",
  },
  {
    value: "settings",
    label: "Settings",
    icon: (
      <GearIcon />
    ),
    group: "Workspace",
  },
  {
    value: "support",
    label: "Support",
    icon: (
      <LifebuoyIcon />
    ),
    group: "Workspace",
    disabled: true,
  },
]

const brand = (
  <SidebarMenu>
    <SidebarMenuItem>
      <SidebarMenuButton size="lg">
        <div className="flex size-8 shrink-0 items-center justify-center rounded-xl bg-primary text-sm font-semibold text-primary-foreground">
          A
        </div>
        <span className="truncate font-medium">Acme Inc.</span>
      </SidebarMenuButton>
    </SidebarMenuItem>
  </SidebarMenu>
)

const account = (
  <SidebarMenu>
    <SidebarMenuItem>
      <SidebarMenuButton size="lg">
        <div className="flex size-8 shrink-0 items-center justify-center rounded-full bg-secondary text-xs font-medium">
          JL
        </div>
        <div className="grid flex-1 text-start leading-tight">
          <span className="truncate text-sm font-medium">Jordan Lee</span>
          <span className="truncate text-xs text-muted-foreground">
            [email protected]
          </span>
        </div>
      </SidebarMenuButton>
    </SidebarMenuItem>
  </SidebarMenu>
)

function Page({ title }: { title: string }) {
  return (
    <>
      <header className="flex h-14 shrink-0 items-center gap-2 border-b border-border px-4">
        <AppShellTrigger className="-ms-1" />
        <h1 className="text-base font-semibold">{title}</h1>
      </header>
      <div className="flex flex-1 flex-col gap-4 p-4">
        <div className="grid grid-cols-3 gap-3 md:gap-4">
          {["Revenue", "Active users", "Open issues"].map((label) => (
            <div
              key={label}
              className="flex h-20 flex-col justify-end rounded-2xl bg-muted p-3 md:aspect-video md:h-auto md:p-4"
            >
              <span className="text-sm text-muted-foreground">{label}</span>
            </div>
          ))}
        </div>
        <div className="min-h-64 flex-1 rounded-2xl bg-muted" />
      </div>
    </>
  )
}

function ShellExample(props: React.ComponentProps<typeof AppShell>) {
  const [value, setValue] = React.useState(props.defaultValue ?? "home")
  const title = props.items.find((item) => item.value === value)?.label ?? ""

  return (
    <AppShell {...props} value={value} onValueChange={setValue}>
      <Page title={title} />
    </AppShell>
  )
}

export default function AppShellGrouped() {
  return (
    <div className="min-h-svh">
      <ShellExample items={manyItems} header={brand} footer={account} />
    </div>
  )
}

More tab

Uses eight items with defaultValue so extra items overflow into the mobile tab bar's More panel.

import {
  CalendarBlankIcon,
  ChartBarIcon,
  FolderIcon,
  GearIcon,
  HouseIcon,
  LifebuoyIcon,
  TrayIcon,
  UsersIcon,
} from "@phosphor-icons/react"
import * as React from "react"
import {
  AppShell,
  type AppShellItem,
  AppShellTrigger,
} from "@/components/patterns/app-shell"

const manyItems: AppShellItem[] = [
  {
    value: "home",
    label: "Home",
    icon: (
      <HouseIcon />
    ),
    group: "Platform",
  },
  {
    value: "inbox",
    label: "Inbox",
    icon: (
      <TrayIcon />
    ),
    badge: 12,
    group: "Platform",
  },
  {
    value: "calendar",
    label: "Calendar",
    icon: (
      <CalendarBlankIcon />
    ),
    group: "Platform",
  },
  {
    value: "reports",
    label: "Reports",
    icon: (
      <ChartBarIcon />
    ),
    group: "Platform",
  },
  {
    value: "projects",
    label: "Projects",
    icon: (
      <FolderIcon />
    ),
    group: "Platform",
  },
  {
    value: "team",
    label: "Team",
    icon: (
      <UsersIcon />
    ),
    group: "Workspace",
  },
  {
    value: "settings",
    label: "Settings",
    icon: (
      <GearIcon />
    ),
    group: "Workspace",
  },
  {
    value: "support",
    label: "Support",
    icon: (
      <LifebuoyIcon />
    ),
    group: "Workspace",
    disabled: true,
  },
]

function Page({ title }: { title: string }) {
  return (
    <>
      <header className="flex h-14 shrink-0 items-center gap-2 border-b border-border px-4">
        <AppShellTrigger className="-ms-1" />
        <h1 className="text-base font-semibold">{title}</h1>
      </header>
      <div className="flex flex-1 flex-col gap-4 p-4">
        <div className="grid grid-cols-3 gap-3 md:gap-4">
          {["Revenue", "Active users", "Open issues"].map((label) => (
            <div
              key={label}
              className="flex h-20 flex-col justify-end rounded-2xl bg-muted p-3 md:aspect-video md:h-auto md:p-4"
            >
              <span className="text-sm text-muted-foreground">{label}</span>
            </div>
          ))}
        </div>
        <div className="min-h-64 flex-1 rounded-2xl bg-muted" />
      </div>
    </>
  )
}

function ShellExample(props: React.ComponentProps<typeof AppShell>) {
  const [value, setValue] = React.useState(props.defaultValue ?? "home")
  const title = props.items.find((item) => item.value === value)?.label ?? ""

  return (
    <AppShell {...props} value={value} onValueChange={setValue}>
      <Page title={title} />
    </AppShell>
  )
}

export default function AppShellMoreTab() {
  return (
    <div className="min-h-svh">
      <ShellExample items={manyItems} defaultValue="team" />
    </div>
  )
}

Links

Gives every item an href so each renders as a link to its route.

import {
  CalendarBlankIcon,
  GearIcon,
  HouseIcon,
  TrayIcon,
} from "@phosphor-icons/react"
import * as React from "react"
import {
  AppShell,
  type AppShellItem,
  AppShellTrigger,
} from "@/components/patterns/app-shell"

const items: AppShellItem[] = [
  {
    value: "home",
    label: "Home",
    icon: (
      <HouseIcon />
    ),
  },
  {
    value: "inbox",
    label: "Inbox",
    icon: (
      <TrayIcon />
    ),
    badge: 12,
  },
  {
    value: "calendar",
    label: "Calendar",
    icon: (
      <CalendarBlankIcon />
    ),
  },
  {
    value: "settings",
    label: "Settings",
    icon: (
      <GearIcon />
    ),
  },
]

function Page({ title }: { title: string }) {
  return (
    <>
      <header className="flex h-14 shrink-0 items-center gap-2 border-b border-border px-4">
        <AppShellTrigger className="-ms-1" />
        <h1 className="text-base font-semibold">{title}</h1>
      </header>
      <div className="flex flex-1 flex-col gap-4 p-4">
        <div className="grid grid-cols-3 gap-3 md:gap-4">
          {["Revenue", "Active users", "Open issues"].map((label) => (
            <div
              key={label}
              className="flex h-20 flex-col justify-end rounded-2xl bg-muted p-3 md:aspect-video md:h-auto md:p-4"
            >
              <span className="text-sm text-muted-foreground">{label}</span>
            </div>
          ))}
        </div>
        <div className="min-h-64 flex-1 rounded-2xl bg-muted" />
      </div>
    </>
  )
}

function ShellExample(props: React.ComponentProps<typeof AppShell>) {
  const [value, setValue] = React.useState(props.defaultValue ?? "home")
  const title = props.items.find((item) => item.value === value)?.label ?? ""

  return (
    <AppShell {...props} value={value} onValueChange={setValue}>
      <Page title={title} />
    </AppShell>
  )
}

export default function AppShellLinks() {
  return (
    <div className="min-h-svh">
      <ShellExample
        items={items.map((item) => ({ ...item, href: `#${item.value}` }))}
      />
    </div>
  )
}

Floating

Uses the floating sidebar variant.

import {
  CalendarBlankIcon,
  GearIcon,
  HouseIcon,
  TrayIcon,
} from "@phosphor-icons/react"
import * as React from "react"
import {
  AppShell,
  type AppShellItem,
  AppShellTrigger,
} from "@/components/patterns/app-shell"
import {
  SidebarMenu,
  SidebarMenuButton,
  SidebarMenuItem,
} from "@/components/ui/sidebar"

const items: AppShellItem[] = [
  {
    value: "home",
    label: "Home",
    icon: (
      <HouseIcon />
    ),
  },
  {
    value: "inbox",
    label: "Inbox",
    icon: (
      <TrayIcon />
    ),
    badge: 12,
  },
  {
    value: "calendar",
    label: "Calendar",
    icon: (
      <CalendarBlankIcon />
    ),
  },
  {
    value: "settings",
    label: "Settings",
    icon: (
      <GearIcon />
    ),
  },
]

const brand = (
  <SidebarMenu>
    <SidebarMenuItem>
      <SidebarMenuButton size="lg">
        <div className="flex size-8 shrink-0 items-center justify-center rounded-xl bg-primary text-sm font-semibold text-primary-foreground">
          A
        </div>
        <span className="truncate font-medium">Acme Inc.</span>
      </SidebarMenuButton>
    </SidebarMenuItem>
  </SidebarMenu>
)

function Page({ title }: { title: string }) {
  return (
    <>
      <header className="flex h-14 shrink-0 items-center gap-2 border-b border-border px-4">
        <AppShellTrigger className="-ms-1" />
        <h1 className="text-base font-semibold">{title}</h1>
      </header>
      <div className="flex flex-1 flex-col gap-4 p-4">
        <div className="grid grid-cols-3 gap-3 md:gap-4">
          {["Revenue", "Active users", "Open issues"].map((label) => (
            <div
              key={label}
              className="flex h-20 flex-col justify-end rounded-2xl bg-muted p-3 md:aspect-video md:h-auto md:p-4"
            >
              <span className="text-sm text-muted-foreground">{label}</span>
            </div>
          ))}
        </div>
        <div className="min-h-64 flex-1 rounded-2xl bg-muted" />
      </div>
    </>
  )
}

function ShellExample(props: React.ComponentProps<typeof AppShell>) {
  const [value, setValue] = React.useState(props.defaultValue ?? "home")
  const title = props.items.find((item) => item.value === value)?.label ?? ""

  return (
    <AppShell {...props} value={value} onValueChange={setValue}>
      <Page title={title} />
    </AppShell>
  )
}

export default function AppShellFloating() {
  return (
    <div className="min-h-svh">
      <ShellExample items={items} variant="floating" header={brand} />
    </div>
  )
}

Inset

Uses the inset sidebar variant.

import {
  CalendarBlankIcon,
  GearIcon,
  HouseIcon,
  TrayIcon,
} from "@phosphor-icons/react"
import * as React from "react"
import {
  AppShell,
  type AppShellItem,
  AppShellTrigger,
} from "@/components/patterns/app-shell"
import {
  SidebarMenu,
  SidebarMenuButton,
  SidebarMenuItem,
} from "@/components/ui/sidebar"

const items: AppShellItem[] = [
  {
    value: "home",
    label: "Home",
    icon: (
      <HouseIcon />
    ),
  },
  {
    value: "inbox",
    label: "Inbox",
    icon: (
      <TrayIcon />
    ),
    badge: 12,
  },
  {
    value: "calendar",
    label: "Calendar",
    icon: (
      <CalendarBlankIcon />
    ),
  },
  {
    value: "settings",
    label: "Settings",
    icon: (
      <GearIcon />
    ),
  },
]

const brand = (
  <SidebarMenu>
    <SidebarMenuItem>
      <SidebarMenuButton size="lg">
        <div className="flex size-8 shrink-0 items-center justify-center rounded-xl bg-primary text-sm font-semibold text-primary-foreground">
          A
        </div>
        <span className="truncate font-medium">Acme Inc.</span>
      </SidebarMenuButton>
    </SidebarMenuItem>
  </SidebarMenu>
)

function Page({ title }: { title: string }) {
  return (
    <>
      <header className="flex h-14 shrink-0 items-center gap-2 border-b border-border px-4">
        <AppShellTrigger className="-ms-1" />
        <h1 className="text-base font-semibold">{title}</h1>
      </header>
      <div className="flex flex-1 flex-col gap-4 p-4">
        <div className="grid grid-cols-3 gap-3 md:gap-4">
          {["Revenue", "Active users", "Open issues"].map((label) => (
            <div
              key={label}
              className="flex h-20 flex-col justify-end rounded-2xl bg-muted p-3 md:aspect-video md:h-auto md:p-4"
            >
              <span className="text-sm text-muted-foreground">{label}</span>
            </div>
          ))}
        </div>
        <div className="min-h-64 flex-1 rounded-2xl bg-muted" />
      </div>
    </>
  )
}

function ShellExample(props: React.ComponentProps<typeof AppShell>) {
  const [value, setValue] = React.useState(props.defaultValue ?? "home")
  const title = props.items.find((item) => item.value === value)?.label ?? ""

  return (
    <AppShell {...props} value={value} onValueChange={setValue}>
      <Page title={title} />
    </AppShell>
  )
}

export default function AppShellInset() {
  return (
    <div className="min-h-svh">
      <ShellExample items={items} variant="inset" header={brand} />
    </div>
  )
}

Offcanvas

Sets collapsible to offcanvas so collapsing slides the whole sidebar out of view.

import {
  CalendarBlankIcon,
  GearIcon,
  HouseIcon,
  TrayIcon,
} from "@phosphor-icons/react"
import * as React from "react"
import {
  AppShell,
  type AppShellItem,
  AppShellTrigger,
} from "@/components/patterns/app-shell"
import {
  SidebarMenu,
  SidebarMenuButton,
  SidebarMenuItem,
} from "@/components/ui/sidebar"

const items: AppShellItem[] = [
  {
    value: "home",
    label: "Home",
    icon: (
      <HouseIcon />
    ),
  },
  {
    value: "inbox",
    label: "Inbox",
    icon: (
      <TrayIcon />
    ),
    badge: 12,
  },
  {
    value: "calendar",
    label: "Calendar",
    icon: (
      <CalendarBlankIcon />
    ),
  },
  {
    value: "settings",
    label: "Settings",
    icon: (
      <GearIcon />
    ),
  },
]

const brand = (
  <SidebarMenu>
    <SidebarMenuItem>
      <SidebarMenuButton size="lg">
        <div className="flex size-8 shrink-0 items-center justify-center rounded-xl bg-primary text-sm font-semibold text-primary-foreground">
          A
        </div>
        <span className="truncate font-medium">Acme Inc.</span>
      </SidebarMenuButton>
    </SidebarMenuItem>
  </SidebarMenu>
)

function Page({ title }: { title: string }) {
  return (
    <>
      <header className="flex h-14 shrink-0 items-center gap-2 border-b border-border px-4">
        <AppShellTrigger className="-ms-1" />
        <h1 className="text-base font-semibold">{title}</h1>
      </header>
      <div className="flex flex-1 flex-col gap-4 p-4">
        <div className="grid grid-cols-3 gap-3 md:gap-4">
          {["Revenue", "Active users", "Open issues"].map((label) => (
            <div
              key={label}
              className="flex h-20 flex-col justify-end rounded-2xl bg-muted p-3 md:aspect-video md:h-auto md:p-4"
            >
              <span className="text-sm text-muted-foreground">{label}</span>
            </div>
          ))}
        </div>
        <div className="min-h-64 flex-1 rounded-2xl bg-muted" />
      </div>
    </>
  )
}

function ShellExample(props: React.ComponentProps<typeof AppShell>) {
  const [value, setValue] = React.useState(props.defaultValue ?? "home")
  const title = props.items.find((item) => item.value === value)?.label ?? ""

  return (
    <AppShell {...props} value={value} onValueChange={setValue}>
      <Page title={title} />
    </AppShell>
  )
}

export default function AppShellOffcanvas() {
  return (
    <div className="min-h-svh">
      <ShellExample items={items} collapsible="offcanvas" header={brand} />
    </div>
  )
}

Right side

Places the sidebar on the right with side.

import {
  CalendarBlankIcon,
  GearIcon,
  HouseIcon,
  TrayIcon,
} from "@phosphor-icons/react"
import * as React from "react"
import {
  AppShell,
  type AppShellItem,
  AppShellTrigger,
} from "@/components/patterns/app-shell"
import {
  SidebarMenu,
  SidebarMenuButton,
  SidebarMenuItem,
} from "@/components/ui/sidebar"

const items: AppShellItem[] = [
  {
    value: "home",
    label: "Home",
    icon: (
      <HouseIcon />
    ),
  },
  {
    value: "inbox",
    label: "Inbox",
    icon: (
      <TrayIcon />
    ),
    badge: 12,
  },
  {
    value: "calendar",
    label: "Calendar",
    icon: (
      <CalendarBlankIcon />
    ),
  },
  {
    value: "settings",
    label: "Settings",
    icon: (
      <GearIcon />
    ),
  },
]

const brand = (
  <SidebarMenu>
    <SidebarMenuItem>
      <SidebarMenuButton size="lg">
        <div className="flex size-8 shrink-0 items-center justify-center rounded-xl bg-primary text-sm font-semibold text-primary-foreground">
          A
        </div>
        <span className="truncate font-medium">Acme Inc.</span>
      </SidebarMenuButton>
    </SidebarMenuItem>
  </SidebarMenu>
)

function Page({ title }: { title: string }) {
  return (
    <>
      <header className="flex h-14 shrink-0 items-center gap-2 border-b border-border px-4">
        <AppShellTrigger className="-ms-1" />
        <h1 className="text-base font-semibold">{title}</h1>
      </header>
      <div className="flex flex-1 flex-col gap-4 p-4">
        <div className="grid grid-cols-3 gap-3 md:gap-4">
          {["Revenue", "Active users", "Open issues"].map((label) => (
            <div
              key={label}
              className="flex h-20 flex-col justify-end rounded-2xl bg-muted p-3 md:aspect-video md:h-auto md:p-4"
            >
              <span className="text-sm text-muted-foreground">{label}</span>
            </div>
          ))}
        </div>
        <div className="min-h-64 flex-1 rounded-2xl bg-muted" />
      </div>
    </>
  )
}

function ShellExample(props: React.ComponentProps<typeof AppShell>) {
  const [value, setValue] = React.useState(props.defaultValue ?? "home")
  const title = props.items.find((item) => item.value === value)?.label ?? ""

  return (
    <AppShell {...props} value={value} onValueChange={setValue}>
      <Page title={title} />
    </AppShell>
  )
}

export default function AppShellRightSide() {
  return (
    <div className="min-h-svh">
      <ShellExample items={items} side="right" header={brand} />
    </div>
  )
}

Collapsed

Starts with the sidebar collapsed to icons through defaultOpen={false}.

import {
  CalendarBlankIcon,
  GearIcon,
  HouseIcon,
  TrayIcon,
} from "@phosphor-icons/react"
import * as React from "react"
import {
  AppShell,
  type AppShellItem,
  AppShellTrigger,
} from "@/components/patterns/app-shell"
import {
  SidebarMenu,
  SidebarMenuButton,
  SidebarMenuItem,
} from "@/components/ui/sidebar"

const items: AppShellItem[] = [
  {
    value: "home",
    label: "Home",
    icon: (
      <HouseIcon />
    ),
  },
  {
    value: "inbox",
    label: "Inbox",
    icon: (
      <TrayIcon />
    ),
    badge: 12,
  },
  {
    value: "calendar",
    label: "Calendar",
    icon: (
      <CalendarBlankIcon />
    ),
  },
  {
    value: "settings",
    label: "Settings",
    icon: (
      <GearIcon />
    ),
  },
]

const brand = (
  <SidebarMenu>
    <SidebarMenuItem>
      <SidebarMenuButton size="lg">
        <div className="flex size-8 shrink-0 items-center justify-center rounded-xl bg-primary text-sm font-semibold text-primary-foreground">
          A
        </div>
        <span className="truncate font-medium">Acme Inc.</span>
      </SidebarMenuButton>
    </SidebarMenuItem>
  </SidebarMenu>
)

const account = (
  <SidebarMenu>
    <SidebarMenuItem>
      <SidebarMenuButton size="lg">
        <div className="flex size-8 shrink-0 items-center justify-center rounded-full bg-secondary text-xs font-medium">
          JL
        </div>
        <div className="grid flex-1 text-start leading-tight">
          <span className="truncate text-sm font-medium">Jordan Lee</span>
          <span className="truncate text-xs text-muted-foreground">
            [email protected]
          </span>
        </div>
      </SidebarMenuButton>
    </SidebarMenuItem>
  </SidebarMenu>
)

function Page({ title }: { title: string }) {
  return (
    <>
      <header className="flex h-14 shrink-0 items-center gap-2 border-b border-border px-4">
        <AppShellTrigger className="-ms-1" />
        <h1 className="text-base font-semibold">{title}</h1>
      </header>
      <div className="flex flex-1 flex-col gap-4 p-4">
        <div className="grid grid-cols-3 gap-3 md:gap-4">
          {["Revenue", "Active users", "Open issues"].map((label) => (
            <div
              key={label}
              className="flex h-20 flex-col justify-end rounded-2xl bg-muted p-3 md:aspect-video md:h-auto md:p-4"
            >
              <span className="text-sm text-muted-foreground">{label}</span>
            </div>
          ))}
        </div>
        <div className="min-h-64 flex-1 rounded-2xl bg-muted" />
      </div>
    </>
  )
}

function ShellExample(props: React.ComponentProps<typeof AppShell>) {
  const [value, setValue] = React.useState(props.defaultValue ?? "home")
  const title = props.items.find((item) => item.value === value)?.label ?? ""

  return (
    <AppShell {...props} value={value} onValueChange={setValue}>
      <Page title={title} />
    </AppShell>
  )
}

export default function AppShellCollapsed() {
  return (
    <div className="min-h-svh">
      <ShellExample
        items={items}
        defaultOpen={false}
        header={brand}
        footer={account}
      />
    </div>
  )
}

API Reference

01

AppShell

The root layout that renders the sidebar or tab bar from items and wraps the page content.

PropTypeDefaultDescription
itemsAppShellItem[]RequiredThe destinations, each with a value, label and icon.
valuestring-The selected item, for controlled use.
defaultValuestringFirst itemThe initially selected item when uncontrolled.
onValueChange(value: string) => void-Called when an item is chosen.
headerReact.ReactNode-Content at the top of the sidebar.
footerReact.ReactNode-Content at the bottom of the sidebar.
variant"sidebar" | "floating" | "inset""sidebar"The sidebar style.
collapsible"offcanvas" | "icon" | "none""icon"How the sidebar collapses.
side"left" | "right""left"The side the sidebar sits on.
railbooleantrueShows a rail along the sidebar edge that toggles it.
openboolean-Whether the sidebar is expanded, for controlled use.
defaultOpenbooleantrueWhether the sidebar starts expanded when uncontrolled.
onOpenChange(open: boolean) => void-Called when the sidebar expands or collapses.
moreLabelstring"More"Text of the More tab on mobile.
renderReact.ReactElement<AppShellLink />The element items with an href render as, such as your router's link.

Also accepts the props of React.ComponentProps<typeof AppShellLayout>.

02

AppShellTrigger

A SidebarTrigger that toggles the sidebar on desktop and renders nothing on mobile.

Also accepts the props of React.ComponentProps<typeof SidebarTrigger>.

Accessibility

  • The selected item has aria-current="page".
  • Collapsed sidebar items show their label in a tooltip on hover.
  • AppShellTrigger is labelled Toggle Sidebar, and Cmd+B on macOS or Ctrl+B elsewhere toggles the sidebar.
  • With collapsible="offcanvas", the hidden sidebar is removed from view and the tab order.
  • Disabled items are disabled buttons.

Notes

The sidebar exposes data-state (expanded or collapsed), data-collapsible, data-variant and data-side for styling. On mobile the wrapper carries data-slot="app-shell" and the content data-slot="app-shell-content"; className applies to the content area on both layouts. rail only renders when collapsible is not none.

NextCommand PalettePreviousAction Menu

On this page

  1. 01Installation
    • CLI
    • Manual
  2. 02Usage
  3. 03Examples
    • With header and footer
    • Grouped
    • More tab
    • Links
    • Floating
    • Inset
    • Offcanvas
    • Right side
    • Collapsed
  4. 04API Reference
    • AppShell
    • AppShellTrigger
  5. 05Accessibility
  6. 06Notes