# App shell

> The frame of an application: a sidebar of navigation that folds into a rail, and a top bar for the page title, search and account.

- Category: Navigation
- Import: `import { AppShell } from "@sagui/ui";`
- Page: /components/app-shell

```tsx
import { useEffect, useState, CSSProperties, MouseEvent } from "react";
import { Search, Settings } from "lucide-react";
import { AppShell, Breadcrumb, Button, CommandPalette, Dialog, DialogContent, MetricCard, NotificationCenter, UserMenu } from "@sagui/ui";

export function Hero() {
  const [current, setCurrent] = useState("/app");
  const [searching, setSearching] = useState(false);

  // In a real app the links navigate; in this preview a click only moves the current page.
  function stayHere(event: MouseEvent) {
    const link = (event.target as HTMLElement).closest<HTMLAnchorElement>('a[href^="/app"]');
    if (!link) return;
    event.preventDefault();
    setCurrent(link.getAttribute("href")!);
  }

  // Cmd/Ctrl + K opens search from anywhere in the app.
  useEffect(() => {
    function onKey(event: KeyboardEvent) {
      if (event.key.toLowerCase() === "k" && (event.metaKey || event.ctrlKey)) {
        event.preventDefault();
        setSearching(true);
      }
    }
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, []);

  return (
    <div onClickCapture={stayHere} className="h-[560px] w-full overflow-y-auto rounded-[var(--radius-lg)] border border-border" style={{ "--app-shell-height": "558px" } as CSSProperties}>
      <AppShell
        nav={nav}
        currentHref={current}
        brand={<span className="flex items-center gap-2 font-semibold"><span className="grid size-7 place-items-center rounded-control bg-primary text-xs text-primary-foreground">S</span>Sales</span>}
        brandMark={<span className="grid size-7 place-items-center rounded-control bg-primary text-xs font-semibold text-primary-foreground">S</span>}
        header={<Breadcrumb items={[{ label: "Acme Inc.", href: "/app" }, { label: titles[current] }]} />}
        actions={
          <>
            <Button variant="ghost" size="sm" leadingIcon={<Search />} onClick={() => setSearching(true)}>
              <span className="hidden sm:inline">Search</span>
            </Button>
            <NotificationCenter notifications={notifications} />
            <UserMenu user={{ name: "Sagnik Dey", email: "sagnik@example.com", plan: "Pro", avatarSrc: face("photo-1507003211169-0a1dd7228f2d") }} items={[{ label: "Profile" }, { label: "Settings", icon: <Settings />, keys: ["⌘", ","] }]} onSignOut={() => {}} />
          </>
        }
      >
        <div className="grid gap-6 p-6">
          <h1 className="type-h2">{titles[current]}</h1>
          <div className="grid gap-4 sm:grid-cols-3">
            <MetricCard label="Revenue" value={128.4} prefix="$" suffix="k" decimals={1} change="+12.4%" context="vs last month" />
            <MetricCard label="Deals won" value={42} change="+6" context="vs last month" />
            <MetricCard label="Win rate" value={31.5} suffix="%" decimals={1} change="+2.1 pts" context="vs last month" />
          </div>
          <p className="type-body-sm text-muted-foreground">Press ⌘B to fold the sidebar into a rail, and ⌘K to search. Narrow the window to see the drawer.</p>
        </div>
      </AppShell>

      <Dialog open={searching} onOpenChange={setSearching}>
        <DialogContent title="Search" className="p-0">
          <CommandPalette
            items={commands}
            autoFocus
            placeholder="Search pages and actions"
            onSelect={(item) => {
              if (item.id.startsWith("/app")) setCurrent(item.id);
              setSearching(false);
            }}
            onClose={() => setSearching(false)}
          />
        </DialogContent>
      </Dialog>
    </div>
  );
}
```

## When to use

- The outer layout of a product: a dashboard, an admin tool, a workspace with several sections.
- Apps with five to fifteen destinations that people move between all day.
- Screens that need the most width on demand, where the sidebar can fold into an icon rail.

## When not to use

- Marketing sites and docs. They need a site header, not a sidebar.
- Two or three destinations. Use [tabs](/components/tabs) in the page instead.
- Moving between views of one dataset. That is [tabs](/components/tabs) or a [segmented control](/components/segmented-control), not navigation.

## Installation

Install the package and import the styles once. The [installation guide](/docs/installation) covers the Tailwind setup.

```bash
npm install @sagui/ui
```

## Usage

Render it once in the layout that wraps your app's pages, and pass it the current path so the right item is marked.

```tsx title="app/(app)/layout.tsx"
"use client";

import Link from "next/link";
import { usePathname } from "next/navigation";
import { Handshake, LayoutDashboard, Settings } from "lucide-react";
import { AppShell } from "@sagui/ui";

const nav = [
  { items: [
    { label: "Overview", href: "/", icon: <LayoutDashboard /> },
    { label: "Deals", href: "/deals", icon: <Handshake />, badge: 12 },
  ] },
  { label: "Workspace", items: [{ label: "Settings", href: "/settings", icon: <Settings /> }] },
];

export default function AppLayout({ children }: { children: React.ReactNode }) {
  return (
    <AppShell nav={nav} currentHref={usePathname()} linkComponent={Link} brand={<span className="font-semibold">Sales</span>}>
      {children}
    </AppShell>
  );
}
```

The shell gives you the frame; you fill its slots. Put the page title or a [breadcrumb](/components/breadcrumb) in `header`. Put search, [notifications](/components/notification-center) and the [user menu](/components/user-menu) in `actions`. Open a [command palette](/components/command-palette) in a dialog for search.

## Examples

### Starting as a rail

`defaultCollapsed` starts with the icon rail. Each icon names itself in a tooltip on hover or focus.

```tsx
import { CSSProperties } from "react";
import { AppShell } from "@sagui/ui";

export function StartFolded() {
  return (
    <div className="h-[360px] w-full overflow-hidden rounded-[var(--radius-lg)] border border-border" style={{ "--app-shell-height": "358px" } as CSSProperties}>
      <AppShell nav={nav} currentHref="/app/deals" defaultCollapsed brand={<span className="font-semibold">Sales</span>} brandMark={<span className="font-semibold">S</span>} header={<span className="type-title">Deals</span>}>
        <p className="p-6 text-sm text-muted-foreground">Point at an icon to see its label.</p>
      </AppShell>
    </div>
  );
}
```

### Inset sidebar

`variant="inset"` floats the sidebar as a rounded panel with a small gap around it, and drops the border under the top bar. The fold toggle moves into the top bar, before the `header`, so the sidebar footer holds only `sidebarFooter`. Use it for dashboards and tools where the content sits in panels of its own.

```tsx
import { CSSProperties } from "react";
import { AppShell } from "@sagui/ui";

export function Inset() {
  return (
    <div className="h-[360px] w-full overflow-hidden rounded-[var(--radius-lg)] border border-border" style={{ "--app-shell-height": "358px" } as CSSProperties}>
      <AppShell
        nav={nav}
        variant="inset"
        currentHref="/app/deals"
        brand={<span className="font-semibold">Sales</span>}
        brandMark={<span className="font-semibold">S</span>}
        header={<span className="type-title">Deals</span>}
        sidebarFooter={<span className="type-body-sm text-muted-foreground">Acme workspace</span>}
      >
        <p className="p-6 text-sm text-muted-foreground">The toggle beside the title folds the floating sidebar into a rail.</p>
      </AppShell>
    </div>
  );
}
```

### Remembering the choice

The shell does not store whether the sidebar is folded. Control it with `collapsed` and `onCollapsedChange`, and save the choice where it suits your app, for example a cookie, so the server renders the right width on the next visit.

```tsx
const [collapsed, setCollapsed] = useState(initialFromCookie);

<AppShell nav={nav} collapsed={collapsed} onCollapsedChange={(next) => { setCollapsed(next); document.cookie = `sidebar=${next ? "rail" : "open"}; path=/; max-age=31536000`; }}>
```

## API reference

### AppShell

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `nav` (required) | `{ label?: string; items: { label: string; href: string; icon?: ReactNode; badge?: ReactNode; current?: boolean }[] }[]` | – | Navigation in sections. A section label is a small heading; `badge` is a count or a Badge after the label. |
| `children` (required) | `ReactNode` | – | The page. It renders in the main column under the top bar. |
| `currentHref` | `string` | – | The current path. The item whose href matches it, or is its closest parent, is marked current, so `/deals/42` marks Deals. |
| `linkComponent` | `ElementType` | `"a"` | Element for nav links. Pass your router's Link, such as next/link. It must forward its ref. |
| `brand` | `ReactNode` | – | Logo and product name at the top of the sidebar and the drawer. |
| `brandMark` | `ReactNode` | – | A compact mark shown instead of `brand` while the sidebar is a rail. |
| `header` | `ReactNode` | – | Start of the top bar: the page title or a Breadcrumb. |
| `actions` | `ReactNode` | – | End of the top bar: search, notifications and the user menu. |
| `sidebarFooter` | `ReactNode` | – | Pinned to the bottom of the sidebar, above the fold toggle in the `sidebar` variant. Hidden while the sidebar is a rail. |
| `variant` | `"sidebar" \| "inset"` | `"sidebar"` | `sidebar` sits flush with a dividing border. `inset` floats the sidebar as a rounded panel and puts the fold toggle in the top bar. |
| `collapsed` | `boolean` | – | Controlled rail state on wide screens. |
| `defaultCollapsed` | `boolean` | `false` | Rail state on first render when uncontrolled. |
| `onCollapsedChange` | `(collapsed: boolean) => void` | – | Called when the toggle or Cmd/Ctrl + B folds or opens the sidebar. |
| `label` | `string` | `"Main"` | Accessible name of the navigation landmark. |
| `className` | `string` | – | Class on the outer grid. |

The shell fills the viewport. To place it inside a fixed-height panel, set `--app-shell-height` on an ancestor, for example `style={{ "--app-shell-height": "560px" }}`.

## Keyboard interactions

| Keys | Action |
| --- | --- |
| Tab | The first stop is a "Skip to content" link, then the navigation, the top bar and the page. |
| Cmd + B / Ctrl + B | Folds the sidebar into a rail, or opens it again. Ignored while typing in a field. |
| Enter | Follows the focused link. |
| Escape | Closes the navigation drawer on narrow screens. |

## Accessibility

- The sidebar is a `nav` landmark named by `label`, and the page is a `main` landmark that the skip link jumps to.
- The current item has `aria-current="page"`, so screen readers announce it as the current page.
- In the rail, labels stay in the accessible name of each link, and a tooltip shows them on hover and keyboard focus.
- The fold toggle reports its state with `aria-expanded` and names its action: "Collapse sidebar" or "Expand sidebar".
- The drawer on narrow screens is a modal dialog with a title, so focus stays inside it until it closes.

## Motion

- The sidebar's width springs between full and rail, and the main column reflows on the same spring, so content does not jump.
- The current page's highlight glides from item to item.
- Labels and section headings fade as the rail closes.
- With reduced motion the width and highlight change in one step.

## Responsive behavior

- From 1024px the sidebar is a sticky column beside the page, 248px open or 68px as a rail.
- Below 1024px the sidebar hides and a menu button in the top bar opens the same navigation in a drawer from the start edge. Choosing a page closes the drawer.
- The main column has `min-width: 0`, so wide tables scroll inside it instead of widening the page.

## Performance

- The shell renders the navigation twice, once for the sidebar and once for the drawer, which only mounts while open.
- Folding animates one width; nothing re-renders on scroll.

## Notes

- Render it once in the app's layout so it persists across pages, and pass `currentHref` from the router.
- Keep icons on every item, or on none. A rail with missing icons cannot be read.
- SagUI's App shell is an original component. Arc's own app shell blocks are part of Arc Pro and are not included.

## Related

- [Breadcrumb](/components/breadcrumb): Show where a page sits in a hierarchy.
- [Command palette](/components/command-palette): A keyboard driven search for pages and actions.
- [User menu](/components/user-menu): Account, theme and sign out behind the avatar.
- [Notification center](/components/notification-center): A home for updates with read state.
- [Drawer](/components/drawer): A temporary side surface for focused work.
