# 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.

Source: https://ui.suiss.app/docs/patterns/app-shell

**React**

**Example**

```tsx
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

```bash title="Terminal"
npx shadcn@latest add @esuiss/app-shell
```

Works in any project with a `components.json`. See [Installation](https://ui.suiss.app/docs/installation#add-components) for the one-line registry setup. Or use the suiss/ui CLI:

```bash title="Terminal"
npx @esuiss/ui@latest add patterns app-shell
```

### Manual

1. Add the [`cn` helper](https://ui.suiss.app/docs/installation#the-cn-helper).

2. Add the pieces it builds on: [Sidebar](https://ui.suiss.app/docs/components/sidebar), [Tab Bar](https://ui.suiss.app/docs/components/tab-bar).

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

```tsx title="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,
  SidebarProvider,
  SidebarRail,
  SidebarTrigger,
  useSidebar,
} from "@/components/ui/sidebar"
import {
  TabBar,
  TabBarContent,
  TabBarItem,
  TabBarList,
  TabBarTrigger,
} from "@/components/ui/tab-bar"
import { cn } from "@/lib/utils"

type AppShellItem = {
  value: string
  label: string
  icon: React.ReactNode
  href?: string
  badge?: React.ReactNode
  group?: string
  disabled?: boolean
}

type AppShellGroup = {
  value: string
  items: AppShellItem[]
}

function groupItems(items: AppShellItem[]): AppShellGroup[] {
  const groups = new Map<string, AppShellItem[]>()
  for (const item of items) {
    const key = item.group ?? ""
    groups.set(key, [...(groups.get(key) ?? []), item])
  }
  return [...groups].map(([value, groupItems]) => ({
    value,
    items: groupItems,
  }))
}

function AppShellLink(props: React.ComponentProps<"a">) {
  return <a {...props} />
}

function AppShell(props: React.ComponentProps<typeof AppShellLayout>) {
  return (
    <SidebarProvider
      defaultOpen={props.defaultOpen}
      open={props.open}
      onOpenChange={props.onOpenChange}
    >
      <AppShellLayout {...props} />
    </SidebarProvider>
  )
}

function AppShellLayout({
  items,
  value: valueProp,
  defaultValue,
  onValueChange,
  header,
  footer,
  variant,
  collapsible = "icon",
  side,
  rail = true,
  moreLabel = "More",
  label = "Main",
  render = <AppShellLink />,
  className,
  children,
}: {
  items: AppShellItem[]
  value?: string
  defaultValue?: string
  onValueChange?: (value: string) => void
  header?: React.ReactNode
  footer?: React.ReactNode
  variant?: "sidebar" | "floating" | "inset"
  collapsible?: "offcanvas" | "icon" | "none"
  side?: "left" | "right"
  rail?: boolean
  defaultOpen?: boolean
  open?: boolean
  onOpenChange?: (open: boolean) => void
  moreLabel?: string
  label?: string
  render?: React.ReactElement
  className?: string
  children?: React.ReactNode
}) {
  const { isMobile } = useSidebar()
  const [uncontrolledValue, setUncontrolledValue] = React.useState(
    defaultValue ?? items[0]?.value
  )
  const value = valueProp ?? uncontrolledValue
  const groups = React.useMemo(() => groupItems(items), [items])

  const select = (next: string) => {
    if (next === value) return
    if (valueProp === undefined) setUncontrolledValue(next)
    onValueChange?.(next)
  }

  if (isMobile) {
    return (
      <div
        data-slot="app-shell"
        className="flex min-h-svh w-full flex-col bg-background"
      >
        <main
          data-slot="app-shell-content"
          className={cn(
            "flex flex-1 flex-col pb-[calc(--spacing(16)+env(safe-area-inset-bottom))]",
            className
          )}
        >
          {children}
        </main>
        <AppShellTabBar
          items={items}
          value={value}
          onSelect={select}
          moreLabel={moreLabel}
          label={label}
          render={render}
        />
      </div>
    )
  }

  return (
    <>
      <Sidebar variant={variant} collapsible={collapsible} side={side}>
        {header && <SidebarHeader>{header}</SidebarHeader>}
        <SidebarContent>
          {groups.map((group) => (
            <SidebarGroup key={group.value}>
              {group.value && (
                <SidebarGroupLabel>{group.value}</SidebarGroupLabel>
              )}
              <SidebarGroupContent>
                <SidebarMenu>
                  {group.items.map((item) => (
                    <SidebarMenuItem key={item.value}>
                      <SidebarMenuButton
                        isActive={item.value === value}
                        tooltip={item.label}
                        disabled={item.disabled}
                        aria-current={item.value === value ? "page" : undefined}
                        render={item.href ? render : undefined}
                        {...(item.href && { href: item.href })}
                        onClick={() => select(item.value)}
                      >
                        {item.icon}
                        <span>{item.label}</span>
                      </SidebarMenuButton>
                      {item.badge !== undefined && (
                        <SidebarMenuBadge>{item.badge}</SidebarMenuBadge>
                      )}
                    </SidebarMenuItem>
                  ))}
                </SidebarMenu>
              </SidebarGroupContent>
            </SidebarGroup>
          ))}
        </SidebarContent>
        {footer && <SidebarFooter>{footer}</SidebarFooter>}
        {rail && collapsible !== "none" && <SidebarRail />}
      </Sidebar>
      <SidebarInset className={className}>{children}</SidebarInset>
    </>
  )
}

function AppShellTabBar({
  items,
  value,
  onSelect,
  moreLabel,
  label,
  render,
}: {
  items: AppShellItem[]
  value: string | undefined
  onSelect: (value: string) => void
  moreLabel: string
  label: string
  render: React.ReactElement
}) {
  return (
    <TabBar aria-label={label}>
      <TabBarList>
        {items.map((item) => (
          <TabBarItem
            key={item.value}
            isActive={item.value === value}
            disabled={item.disabled}
            render={item.href ? render : undefined}
            {...(item.href && { href: item.href })}
            onClick={() => onSelect(item.value)}
          >
            {item.label}
          </TabBarItem>
        ))}
      </TabBarList>
      <TabBarContent />
      <TabBarTrigger>{moreLabel}</TabBarTrigger>
    </TabBar>
  )
}

function AppShellTrigger(props: React.ComponentProps<typeof SidebarTrigger>) {
  const { isMobile } = useSidebar()

  if (isMobile) return null

  return <SidebarTrigger {...props} />
}

export { AppShell, type AppShellItem, AppShellTrigger }
```

4. Update the import paths to match your project.

## Usage

```tsx
import { AppShell, AppShellTrigger } from "@/components/patterns/app-shell"
```

```tsx
<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.

**Example**

```tsx
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">
            jordan@acme.com
          </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.

**Example**

```tsx
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">
            jordan@acme.com
          </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.

**Example**

```tsx
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.

**Example**

```tsx
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.

**Example**

```tsx
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.

**Example**

```tsx
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.

**Example**

```tsx
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`.

**Example**

```tsx
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}`.

**Example**

```tsx
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">
            jordan@acme.com
          </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

### AppShell

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

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `items` | `AppShellItem[]` | Required | The destinations, each with a `value`, `label` and `icon`. |
| `value` | `string` | - | The selected item, for controlled use. |
| `defaultValue` | `string` | First item | The initially selected item when uncontrolled. |
| `onValueChange` | `(value: string) => void` | - | Called when an item is chosen. |
| `header` | `React.ReactNode` | - | Content at the top of the sidebar. |
| `footer` | `React.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. |
| `rail` | `boolean` | `true` | Shows a rail along the sidebar edge that toggles it. |
| `open` | `boolean` | - | Whether the sidebar is expanded, for controlled use. |
| `defaultOpen` | `boolean` | `true` | Whether the sidebar starts expanded when uncontrolled. |
| `onOpenChange` | `(open: boolean) => void` | - | Called when the sidebar expands or collapses. |
| `moreLabel` | `string` | `"More"` | Text of the More tab on mobile. |
| `render` | `React.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>`.

### 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`.

