# App Shell

> A React app shell for admin layouts: a collapsible, resizable sidebar, a top bar and the page, with a drawer and a tab bar on phones.

- Type: React component (Layout)
- Access: Pro (the source needs a Wingo UI Pro plan, see https://wingo-ui.com/pricing)
- Version: 1.1.0
- Install: `npx wingo-ui@latest add app-shell`
- Docs: https://wingo-ui.com/components/app-shell

## Interaction

On desktop the sidebar sits beside the page: press Mod+B (Cmd on a Mac, Ctrl elsewhere), the top bar's panel button or the sidebar's collapse button to fold it into a 64px icon rail (or, with collapsible="offcanvas", to slide it out completely); the width animates and the state is remembered in a cookie. With resizable, drag the sidebar's edge between 200 and 400px (the width follows the pointer and is saved on release), drag it below 120px to snap to the rail, double-click it to reset, or focus it with Tab and use Left / Right (16px), Home (rail), End (widest) and Enter (fold / unfold). Below the breakpoint the sidebar becomes a drawer: the top bar's menu button or Mod+B opens it, it closes on navigation, on Escape, on a tap on the dimmed page or by swiping it back, and focus returns to the menu button; with edgeSwipe a swipe from the start edge opens it too. The bottom tab bar shows on phones only. Tab once from the top of the page to reveal "Skip to content", which jumps straight to the page.

## Installation

```bash
npx wingo-ui@latest add app-shell
```

Pro item: run `npx wingo-ui@latest login` once with a Pro account. The CLI adds the files, their dependencies and a version record for updates.

With shadcn: `npx shadcn@latest add @wingo-ui/app-shell` (needs the @wingo-ui registry with your API key in components.json)

Files:

- `components/ui/app-shell.tsx`

Wingo UI dependencies (installed automatically):

- [Sidebar](https://wingo-ui.com/components/sidebar) ([Markdown](https://wingo-ui.com/components/sidebar.md))
- [Topbar](https://wingo-ui.com/components/topbar) ([Markdown](https://wingo-ui.com/components/topbar.md))
- [Bottom Tab Bar](https://wingo-ui.com/components/bottom-tab-bar) ([Markdown](https://wingo-ui.com/components/bottom-tab-bar.md))
- [Sheet](https://wingo-ui.com/components/sheet) ([Markdown](https://wingo-ui.com/components/sheet.md))
- [Navigation](https://wingo-ui.com/components/navigation) ([Markdown](https://wingo-ui.com/components/navigation.md))
- [useControllableState](https://wingo-ui.com/components/use-controllable-state) ([Markdown](https://wingo-ui.com/components/use-controllable-state.md))
- [useMediaQuery](https://wingo-ui.com/components/use-media-query) ([Markdown](https://wingo-ui.com/components/use-media-query.md))
- [usePersistedState](https://wingo-ui.com/components/use-persisted-state) ([Markdown](https://wingo-ui.com/components/use-persisted-state.md))
- [useScrollState](https://wingo-ui.com/components/use-scroll-state) ([Markdown](https://wingo-ui.com/components/use-scroll-state.md))
- [useShortcut](https://wingo-ui.com/components/use-shortcut) ([Markdown](https://wingo-ui.com/components/use-shortcut.md))
- [Motion presets](https://wingo-ui.com/components/motion) ([Markdown](https://wingo-ui.com/components/motion.md))
- [UIProvider](https://wingo-ui.com/components/ui-config) ([Markdown](https://wingo-ui.com/components/ui-config.md))

## Usage

```tsx
import type { ReactNode } from "react"
import Link from "next/link"
import { cookies } from "next/headers"
import { usePathname, useRouter } from "next/navigation"
import { FileText, House, Inbox, LayoutDashboard, Settings, Users } from "lucide-react"
import { AppShell } from "@/components/ui/app-shell"
import { Sidebar, type SidebarNavGroup } from "@/components/ui/sidebar"
import { Topbar } from "@/components/ui/topbar"
import { persistedCookieName } from "@/hooks/use-persisted-state"
import { LinkProvider, type NavItem } from "@/lib/navigation"

// app/providers.tsx ("use client"): every NavLink inside renders through next/link
export function Providers({ children }: { children: ReactNode }) {
  const router = useRouter()
  return (
    <LinkProvider component={Link} currentPath={usePathname()} navigate={router.push}>
      {children}
    </LinkProvider>
  )
}

const nav: SidebarNavGroup[] = [
  {
    items: [
      { label: "Home", href: "/", icon: <LayoutDashboard /> },
      { label: "Inbox", href: "/inbox", icon: <Inbox />, badge: 4 },
    ],
  },
  {
    label: "Sales",
    items: [
      { label: "Clients", href: "/clients", icon: <Users /> },
      { label: "Invoices", href: "/invoices", icon: <FileText /> },
    ],
  },
  { label: "Account", items: [{ label: "Settings", href: "/settings", icon: <Settings /> }] },
]

const tabs: NavItem[] = [
  { label: "Home", href: "/", icon: <House /> },
  { label: "Clients", href: "/clients", icon: <Users /> },
  { label: "Inbox", href: "/inbox", icon: <Inbox />, badge: 4 },
  { label: "Settings", href: "/settings", icon: <Settings /> },
]

// app/(app)/layout.tsx, a server component: the cookie renders the right sidebar on the first paint
export default async function AppLayout({ children }: { children: ReactNode }) {
  const stored = (await cookies()).get(persistedCookieName("app-shell-collapsed"))?.value
  return (
    <AppShell
      defaultCollapsed={stored === "true"}
      sidebar={<Sidebar groups={nav} filter />}
      topbar={<Topbar title="Summit CRM" />}
      tabs={tabs}
      labels={{ openMenu: "Open menu", closeMenu: "Close menu", collapse: "Collapse", expand: "Expand", navigation: "Main" }}
      skipLinkLabel="Skip to content"
    >
      {children}
    </AppShell>
  )
}
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `sidebar?` | `ReactNode` |  | A Sidebar. The shell renders it in the desktop column (it drives its collapsed state, width and variant) and, on phones, a second copy with mode="sheet" inside the drawer, mounted only while the drawer is open. A wrapper component must forward its props (mode, variant, collapsible, width, className, style) to the Sidebar. |
| `nav?` | `SidebarNavGroup[]` |  | A shortcut for small apps: renders <Sidebar groups={nav} /> when sidebar is not given. |
| `topbar?` | `ReactNode` |  | A Topbar, sticky at the top of the content column. It renders the menu button (sidebar toggle on desktop, hamburger on phones) and gets aria-controls and the shortcut hint from the shell unless you set menuControls / menuShortcut (pass the Topbar element itself, not a wrapper component, for that). For your own header use AppShellTrigger (see Parts). |
| `tabs?` | `ReactNode \| NavItem[]` |  | Phones only: a BottomTabBar, or just its items. Hidden from mobileBreakpoint up. Fixed to the viewport with window scroll (the page is padded by --bottom-bar-offset), a static row under the page with content scroll. |
| `banner?` | `ReactNode` |  | A full-width strip above everything, e.g. an Alert with radius="none" for a maintenance notice. |
| `children?` | `ReactNode` |  | The page, rendered in <main id={mainId}>. The shell never animates it on navigation. Components inside it read the shell state with useAppShell (see Parts). |
| `variant?` | `"sidebar" \| "inset" \| "floating"` | `"sidebar"` | sidebar: flush columns with a hairline. inset: the page sits in a raised rounded panel on a tinted shell (Linear, Vercel). floating: the sidebar is a raised panel 8px off the edges. Sets the sidebar's variant unless it has its own. |
| `defaultCollapsed?` | `boolean` | `false` | Starts with the rail (or the sidebar hidden, with offcanvas). A stored cookie wins after hydration; read it on the server (persistedCookieName(`${persistKey}-collapsed`)) and pass it here so the first paint is right. |
| `collapsed?` | `boolean` |  | Controlled collapsed state of the desktop sidebar. Pair it with onCollapsedChange. |
| `onCollapsedChange?` | `(collapsed: boolean) => void` |  | Every change: the shortcut, the top bar button, the sidebar's collapse button, a drag to the rail. |
| `collapsible?` | `"icon" \| "offcanvas" \| "none"` | `"icon"` | icon: the sidebar folds into a 64px rail with tooltips. offcanvas: it slides out completely and the page takes the width. none: always expanded (the desktop toggle disappears; phones keep the drawer). |
| `persistKey?` | `string` | `"app-shell"` | Remembers collapsed and the width in the cookies `${persistKey}-collapsed` and `${persistKey}-width` (a server layout can read them). "" keeps both in memory. |
| `defaultSidebarWidth?` | `number` | `256` | The expanded sidebar width in px, and what a double-click on the resize handle resets to. |
| `sidebarWidth?` | `number` |  | Controlled width in px. Pair it with onSidebarWidthChange. |
| `onSidebarWidthChange?` | `(width: number) => void` |  | Fires once per change (pointer up, a key press, a reset), never per frame. |
| `resizable?` | `boolean` | `false` | A handle on the sidebar's end edge: drag between 200 and 400px, below 120px it snaps to the rail, double-click resets. It is a keyboard-operable separator (arrows ±16px, Home, End, Enter). |
| `mobileBreakpoint?` | `"md" \| "lg"` | `"md"` | Below it the sidebar becomes a drawer and the tabs show: md (768px) or lg (1024px, tablets get the phone layout). Layout switches with CSS, so the server HTML is right. |
| `defaultMobileOpen?` | `boolean` | `false` | Starts with the phone drawer open (it only shows below the breakpoint). |
| `mobileOpen?` | `boolean` |  | Controlled drawer state. Pair it with onMobileOpenChange. |
| `onMobileOpenChange?` | `(open: boolean) => void` |  | The drawer opened or closed (menu button, shortcut, edge swipe, navigation, Escape, a tap outside, a swipe back). |
| `mobileDrawerSize?` | `"sm" \| "md"` | `"sm"` | The drawer's width: 320 or 400px, always leaving a 48px sliver of the page to tap. |
| `edgeSwipe?` | `boolean` | `false` | Phones: a 16px strip on the start edge opens the drawer after a 40px drag or a flick. Turn it on in installed PWAs; in Safari that edge is the browser's back gesture. |
| `scroll?` | `"window" \| "content"` | `"window"` | window: the document scrolls (router scroll restoration, the iOS toolbar minimizing; the right default for apps). content: the content column scrolls on its own and is provided to the bars as their scroll container (embedded apps, iframes, this demo). Docs-only: the playground always runs in content mode inside its frame. |
| `height?` | `"screen" \| "full"` | `"screen"` | screen: the viewport height (dvh). full: fills the parent, which needs a height. Docs-only: the playground frame always uses full. |
| `shortcut?` | `string` | `"mod+b"` | Toggles the sidebar: the rail on desktop, the drawer on phones. Shown in the top bar button's tooltip. "" binds nothing. |
| `skipLinkLabel?` | `string` | `"Skip to content"` | The skip link, the first focusable element: hidden until tabbed to, then it focuses the page. |
| `mainId?` | `string` | `"main"` | The id of <main>, the skip link's target. |
| `mainAs?` | `"main" \| "div"` | `"main"` | The content column's element. Keep main for the app's own shell; use div when the shell renders inside a page that already has its <main> (a docs preview, an embedded demo). |
| `container?` | `HTMLElement \| null` |  | Where the drawer is portaled (default document.body). |
| `labels?` | `{ openMenu?: string; closeMenu?: string; collapse?: string; expand?: string; resize?: string; collapsed?: string; navigation?: string }` |  | English defaults ("Open menu", "Close menu", "Collapse sidebar", "Expand sidebar", "Resize sidebar", "Collapsed", "Navigation"). The first four are shared with the top bar's menu button through the shell context; resize names the resize handle, collapsed is its value text while the sidebar is a rail, navigation is the drawer's hidden title. |
| `classNames?` | `Partial<Record<"sidebar" \| "body" \| "main" \| "topbar" \| "tabs" \| "drawer" \| "resize", string>>` |  | Extra classes for the parts: the desktop sidebar column, the content column (the raised panel in inset), <main>, the top bar, the tabs row, the drawer body and the resize handle. |
| `className?` | `string` |  | Extra classes for the root. |

## Parts

### useAppShell (hook)

The shell state for anything inside it: isMobile, collapsed / setCollapsed, mobileOpen / setMobileOpen, toggleSidebar, hasSidebar, scroll, labels, mainRef, sidebarWidth / setSidebarWidth, shortcut, and sidebarId / drawerId for aria-controls. Throws outside an AppShell.

### AppShellTrigger (component)

The menu button for your own header: the sidebar toggle with a tooltip on desktop, a hamburger that turns into an X on phones, nothing when the shell has no sidebar. Takes the Button props plus kind, shortcut, tooltip and labels; aria-controls and the shortcut come from the shell.

## Examples

- **Inset**: variant="inset": the page sits in a raised rounded panel on a tinted shell, and the top bar glass matches the panel.
- **Floating, off-canvas**: variant="floating" + collapsible="offcanvas": the panel button in the top bar slides the whole sidebar out and the page takes the width.
- **Resizable, remembered**: Drag the sidebar's edge (or focus it and use the arrows); below 120px it snaps to the rail, double-click resets. The width survives a reload.
- **No sidebar: top bar and tabs**: A mobile-first app: links in the top bar on wide screens, a bottom tab bar on phones. No menu button, no drawer, no shortcut.
- **Maintenance banner**: banner: a full-width strip above the shell (an Alert with banner), here with the sidebar starting as a rail.
- **On a phone: drawer and tabs**: The same app at 390px with the drawer open: tap the page or swipe the drawer back, and it closes on navigation.

## See also

- [Sidebar](https://wingo-ui.com/components/sidebar) ([Markdown](https://wingo-ui.com/components/sidebar.md))
- [Topbar](https://wingo-ui.com/components/topbar) ([Markdown](https://wingo-ui.com/components/topbar.md))
- [Bottom Tab Bar](https://wingo-ui.com/components/bottom-tab-bar) ([Markdown](https://wingo-ui.com/components/bottom-tab-bar.md))
- [Settings Layout](https://wingo-ui.com/components/settings-layout) ([Markdown](https://wingo-ui.com/components/settings-layout.md))
- [Navigation](https://wingo-ui.com/components/navigation) ([Markdown](https://wingo-ui.com/components/navigation.md))

---

Canonical: https://wingo-ui.com/components/app-shell
More components: https://wingo-ui.com/components · Index for agents: https://wingo-ui.com/llms.txt
