# Mobile Nav

The navigation drawer of an application on a phone.

```tsx
import * as React from 'react'
import { Button, MobileNav, MobileNavTrigger, type NavGroup, type NavItem } from 'ferry-ui'
import { CreditCard, FolderKanban, LayoutDashboard, LogOut, Settings, Users } from 'lucide-react'

const MAIN: NavItem[] = [
  { id: 'overview', label: 'Overview', icon: <LayoutDashboard /> },
  { id: 'projects', label: 'Projects', icon: <FolderKanban /> },
  { id: 'members', label: 'Members', icon: <Users /> },
]

const WORKSPACE: NavItem[] = [
  { id: 'billing', label: 'Billing', icon: <CreditCard /> },
  { id: 'settings', label: 'Settings', icon: <Settings /> },
]

export default function MobileNavHero() {
  // A real app reads the current page from its router. This demo keeps it in state.
  const [page, setPage] = React.useState('overview')
  const withState = (item: NavItem): NavItem => ({
    ...item,
    active: item.id === page,
    onSelect: () => setPage(item.id),
  })
  const groups: NavGroup[] = [
    { id: 'main', items: MAIN.map(withState) },
    { id: 'workspace', label: 'Workspace', items: WORKSPACE.map(withState) },
  ]
  const current = [...MAIN, ...WORKSPACE].find((item) => item.id === page)

  return (
    <div className="min-h-dvh bg-background">
      <header className="flex h-12 items-center gap-2 border-b px-2">
        <MobileNav
          // The trigger hides from 768px. `md:inline-flex` keeps it in view for this demo.
          trigger={<MobileNavTrigger className="md:inline-flex" />}
          groups={groups}
          title="Acme"
          description="Billing portal"
          footer={
            <Button className="w-full" icon={<LogOut />}>
              Sign out
            </Button>
          }
        />
        <span className="text-sm font-medium text-foreground">{current?.label}</span>
      </header>
      <p className="p-6 text-[13px] text-foreground-light">Open the navigation with the menu button.</p>
    </div>
  )
}
```

Use the menu button to open the drawer. The preview starts at the width of a phone.

## Usage guidelines

- **For phones.** Put the drawer in the `mobileNav` slot of [App Shell](/docs/components/app-shell). On wide screens, [Icon Rail](/docs/components/icon-rail) shows the navigation.
- **The same items as the rail.** Give the same `NavGroup[]` to the rail and to the drawer. See [`NavItem`](/docs/components/icon-rail#navitem).
- **Not a general side panel.** For other content, use [Sheet](/docs/components/sheet).
- **The footer is for the sign-out button.** Put the destinations in `groups` or `items`.

## Anatomy

Import the parts and put them together.

```tsx title="Anatomy"

<MobileNav trigger={<MobileNavTrigger />} groups={groups} footer={<Button />}>
  <MobileNavSection />
</MobileNav>
```

| Part | Role |
| --- | --- |
| `MobileNav` | The drawer: a header, the items, the sections and a footer. |
| `MobileNavTrigger` | The menu button. It hides from 768px. |
| `MobileNavSection` | A block with a heading, below the items. |

## Examples

### In an app shell

In an `AppShell`, the drawer follows the state of the shell. It needs no `open` prop and no `trigger`. In a custom top bar, a `MobileNavTrigger` opens the drawer. [Top Bar](/docs/components/top-bar) has its own menu button.

```tsx
<AppShell
  topBar={
    <header className="flex h-12 items-center border-b px-2">
      <MobileNavTrigger />
    </header>
  }
  mobileNav={<MobileNav groups={groups} title="Acme" />}
>
  {children}
</AppShell>
```

### Controlled state

Outside a shell, pass a `trigger` or control the state. To control the state, pass `open` and `onOpenChange`.

```tsx
import * as React from 'react'
import { Button, MobileNav, type NavItem } from 'ferry-ui'
import { FolderKanban, LayoutDashboard, Users } from 'lucide-react'

const ITEMS: NavItem[] = [
  { id: 'overview', label: 'Overview', icon: <LayoutDashboard />, active: true },
  { id: 'projects', label: 'Projects', icon: <FolderKanban /> },
  { id: 'members', label: 'Members', icon: <Users /> },
]

export default function MobileNavControlled() {
  const [open, setOpen] = React.useState(false)

  return (
    <div className="flex min-h-dvh flex-col items-start gap-3 bg-background p-6">
      <Button onClick={() => setOpen(true)}>Open the navigation</Button>
      <p className="text-[13px] text-foreground-light">The drawer is {open ? 'open' : 'closed'}.</p>
      <MobileNav open={open} onOpenChange={setOpen} items={ITEMS} title="Acme" />
    </div>
  )
}
```

### Sections and theme

The children show below the items. Use `MobileNavSection` for a block with a heading. `showThemeToggle` adds a control for the light, dark and system themes. The control changes the theme of the nearest [ThemeProvider](/docs/utilities/theme-provider).

```tsx
import { MobileNav, MobileNavSection, MobileNavTrigger, type NavItem } from 'ferry-ui'
import { FolderKanban, LayoutDashboard, Users } from 'lucide-react'

const ITEMS: NavItem[] = [
  { id: 'overview', label: 'Overview', icon: <LayoutDashboard />, active: true },
  { id: 'projects', label: 'Projects', icon: <FolderKanban /> },
  { id: 'members', label: 'Members', icon: <Users /> },
]

export default function MobileNavSections() {
  return (
    <div className="min-h-dvh bg-background">
      <header className="flex h-12 items-center gap-2 border-b px-2">
        <MobileNav
          trigger={<MobileNavTrigger className="md:inline-flex" />}
          items={ITEMS}
          title="Acme"
          showThemeToggle
        >
          <MobileNavSection label="Workspace">
            <span className="px-1 text-sm text-foreground">Acme Europe</span>
            <span className="px-1 text-[13px] text-foreground-light">Pro plan, 12 members</span>
          </MobileNavSection>
        </MobileNav>
        <span className="text-sm font-medium text-foreground">Overview</span>
      </header>
    </div>
  )
}
```

### Drawer that stays open

By default, the drawer closes when the user selects an item. Set `closeOnSelect` to `false` to keep it open.

```tsx
import * as React from 'react'
import { MobileNav, MobileNavTrigger, type NavItem } from 'ferry-ui'
import { FolderKanban, LayoutDashboard, Users } from 'lucide-react'

const PAGES: NavItem[] = [
  { id: 'overview', label: 'Overview', icon: <LayoutDashboard /> },
  { id: 'projects', label: 'Projects', icon: <FolderKanban /> },
  { id: 'members', label: 'Members', icon: <Users /> },
]

export default function MobileNavKeepOpen() {
  const [page, setPage] = React.useState('overview')
  const items = PAGES.map((item) => ({ ...item, active: item.id === page, onSelect: () => setPage(item.id) }))
  const current = PAGES.find((item) => item.id === page)

  return (
    <div className="min-h-dvh bg-background">
      <header className="flex h-12 items-center gap-2 border-b px-2">
        <MobileNav
          trigger={<MobileNavTrigger className="md:inline-flex" />}
          items={items}
          title="Acme"
          closeOnSelect={false}
        />
        <span className="text-sm font-medium text-foreground">{current?.label}</span>
      </header>
    </div>
  )
}
```

### Right side

`side="right"` moves the drawer to the right edge of the screen.

```tsx
import { MobileNav, MobileNavTrigger, type NavItem } from 'ferry-ui'
import { FolderKanban, LayoutDashboard, Users } from 'lucide-react'

const ITEMS: NavItem[] = [
  { id: 'overview', label: 'Overview', icon: <LayoutDashboard />, active: true },
  { id: 'projects', label: 'Projects', icon: <FolderKanban /> },
  { id: 'members', label: 'Members', icon: <Users /> },
]

export default function MobileNavRightSide() {
  return (
    <div className="min-h-dvh bg-background">
      <header className="flex h-12 items-center justify-between gap-2 border-b px-3">
        <span className="text-sm font-medium text-foreground">Overview</span>
        <MobileNav
          trigger={<MobileNavTrigger className="md:inline-flex" />}
          items={ITEMS}
          title="Acme"
          side="right"
        />
      </header>
    </div>
  )
}
```

## Accessibility

- When the drawer opens, the focus moves to the `active` item. With no active item, the focus moves to the first item.
- `title` is the accessible name of the drawer. The default is "Menu".
- The `aria-label` of `MobileNavTrigger` sets the name of the menu button. The default is "Open navigation".
- `labels` sets the other texts of the drawer. Use it to translate them.

| Key of `labels` | Default text |
| --- | --- |
| `navigation` | "Main" |
| `theme` | "Theme" |
| `light` | "Light" |
| `dark` | "Dark" |
| `system` | "System" |
| `close` | "Close" |
| `external` | "(opens in a new tab)" |

## API reference

### MobileNav

`MobileNav` accepts only the props of this table.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `groups` | `NavGroup[]` |  | Navigation groups, separated by borders; a group `label` shows as a mono heading. Takes precedence over `items`. |
| `items` | `NavItem[]` |  | Shorthand for a single group of items. Ignored when `groups` is set. |
| `open` | `boolean` |  | Controlled open state. Pair with `onOpenChange`. When omitted inside an `AppShell`, the drawer follows the shell's mobile navigation state; to control it there, use the shell's `mobileNavOpen` instead so `MobileNavTrigger` keeps working. |
| `defaultOpen` | `boolean` | `false` | Initial open state when uncontrolled and outside an `AppShell`. Defaults to `false`. |
| `onOpenChange` | `((open: boolean) => void)` |  | Called when the drawer opens or closes (including after an item is chosen). |
| `trigger` | `ReactNode` |  | Element that opens the drawer on click, usually a `MobileNavTrigger`. Not needed inside an `AppShell`. |
| `logo` | `ReactNode` |  | Brand mark shown before the title (about 20px). It sits inside the accessible title, so mark it `aria-hidden`. |
| `title` | `ReactNode` | `Menu` | Drawer title, also its accessible name (usually the app or workspace name). Defaults to `"Menu"`. |
| `description` | `ReactNode` |  | Short line under the title (workspace, plan, version…). |
| `children` | `ReactNode` |  | Extra blocks below the groups, usually `MobileNavSection`s. |
| `footer` | `ReactNode` |  | Bottom slot pinned under the scrolling content, usually a full-width sign-out `Button`. |
| `showThemeToggle` | `boolean` | `false` | Shows a Light / Dark / System segmented control. By default it reads and writes the nearest `ThemeProvider` through `useTheme()` (without a provider and without `theme`, choosing has no effect). Defaults to `false`. |
| `theme` | `"light" \| "dark" \| "system"` |  | Controlled theme preference of the theme control. Omit it to follow the nearest `ThemeProvider`. |
| `onThemeChange` | `((theme: ThemePreference) => void)` |  | Called with the chosen theme preference (the `ThemeProvider` is updated too when `theme` is omitted). |
| `closeOnSelect` | `boolean` | `true` | Closes the drawer when an item is chosen. Defaults to `true`. |
| `side` | `"right" \| "left"` | `left` | Edge the drawer slides in from. Defaults to `"left"`. |
| `linkComponent` | `LinkComponent` |  | Router-aware link used by every item. Defaults to the nearest `LinkProvider` component, else a plain `<a>`. Not used for `external` items (they render a plain `<a target="_blank">`). |
| `labels` | `MobileNavLabels` |  | Built-in texts: the landmark, the theme control, the close button and the suffix of external items. |
| `className` | `string` |  | Extra classes for the drawer panel (default width 280px, at most 85% of the viewport). |

### MobileNavTrigger

`MobileNavTrigger` accepts the props of [Button](/docs/components/button), without `children`.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `variant` | `"link" \| "default" \| "primary" \| "outline" \| "ghost" \| "destructive" \| "destructive-solid" \| "danger" \| "danger-solid" \| "warning" \| "dashed"` |  | Look. `default` (outlined neutral, the default), `primary` (solid: the one main action of a view), `outline` (transparent with a border, on tinted surfaces), `ghost` (borderless, dense rows and toolbars), `destructive` (red outline: starts a destructive action), `destructive-solid` (solid red: the confirm button of a destructive dialog only), `warning` (amber outline), `link` (text link look), `dashed` (filter buttons). `danger` and `danger-solid` are deprecated aliases of `destructive` and `destructive-solid`. |
| `size` | `"tiny" \| "sm" \| "md" \| "lg" \| "icon-tiny" \| "icon" \| "icon-md" \| "icon-lg"` |  | Height. `tiny` 26px (dense inline actions), `sm` 30px (default: toolbars, table rows), `md` 34px (next to form fields), `lg` 38px. Square, icon-only sizes of the same heights: `icon-tiny` 26px, `icon` 30px, `icon-md` 34px, `icon-lg` 38px (they need an `aria-label`). |
| `shape` | `"default" \| "pill"` |  | Corners: `default` (6px radius) or `pill` (fully rounded: top-bar actions, search triggers). |
| `asChild` | `boolean` |  | Style the single child element (usually an `<a>` or a router link) as the button instead of rendering a `<button>`. `icon` / `iconRight` are rendered inside the child. A link has no native disabled state, so `disabled` and `loading` map to ARIA on the child: `aria-disabled` (dimmed, pointer events off), `tabIndex={-1}` and, for `loading`, `aria-busy` plus the spinner. `type` is ignored. |
| `loading` | `boolean` |  | Shows a spinner in place of `icon`, disables the button and sets `aria-busy`. Use it while the action triggered by this button is in flight. |
| `icon` | `ReactNode` |  | Leading icon (rendered before children). Pass a bare lucide icon: it is sized for you. |
| `iconRight` | `ReactNode` |  | Trailing icon (rendered after children). Pass a bare lucide icon: it is sized for you. |
| `aria-label` | `string` |  | Defines a string value that labels the current element. Accessible name of the icon button. Defaults to `"Open navigation"`. |

### MobileNavSection

`MobileNavSection` also accepts each attribute of the `<div>` element.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `label` | `ReactNode` |  | Mono heading of the section (1–3 words). |
