# Inner Menu

A side menu for the sections of one area, such as the settings of an app.

```tsx
import * as React from 'react'
import { InnerMenu, PageContainer, PageHeader, type NavGroup } from 'ferry-ui'
import { BellRing, CreditCard, KeyRound, ShieldCheck, User, Users } from 'lucide-react'

const GROUPS: NavGroup[] = [
  {
    id: 'account',
    label: 'Account',
    items: [
      { id: 'profile', label: 'Profile', icon: <User /> },
      { id: 'notifications', label: 'Notifications', icon: <BellRing /> },
      { id: 'security', label: 'Security', icon: <ShieldCheck /> },
    ],
  },
  {
    id: 'workspace',
    label: 'Workspace',
    items: [
      { id: 'members', label: 'Members', icon: <Users /> },
      { id: 'billing', label: 'Billing', icon: <CreditCard /> },
      { id: 'api-keys', label: 'API keys', icon: <KeyRound /> },
    ],
  },
]

export default function InnerMenuHero() {
  // The menu does not render the sections. Your state (or your router) holds the current one.
  const [section, setSection] = React.useState('profile')
  const current = GROUPS.flatMap((group) => group.items).find((item) => item.id === section)

  return (
    <div className="flex h-dvh flex-col bg-background md:flex-row">
      <InnerMenu title="Settings" groups={GROUPS} value={section} onValueChange={setSection} />
      <div className="min-w-0 flex-1 overflow-y-auto">
        <PageContainer size="narrow">
          <PageHeader title={current?.label} description="The menu selects the section that shows here." />
        </PageContainer>
      </div>
    </div>
  )
}
```

Use the buttons above the preview to see the menu on a phone. Below 768px, the menu becomes a row of tabs that scrolls.

## Usage guidelines

- **For the sections of one area.** Use the menu for a settings area or for the pages of one record.
- **Not the main navigation.** For the main pages of an app, use [Icon Rail](/docs/components/icon-rail).
- **Not for panels in one view.** To change a panel in a page, use [Tabs](/docs/components/tabs).
- **Your code holds the current section.** The menu shows the items. It does not render the sections.

## Anatomy

Put the menu first in a container with the classes `flex flex-col md:flex-row`. Put the content that scrolls after the menu.

```tsx title="Anatomy"

<div className="flex min-h-0 flex-1 flex-col md:flex-row">
  <InnerMenu title="Settings" groups={groups} />
  <div className="min-w-0 flex-1 overflow-y-auto">{/* the section */}</div>
</div>
```

`groups` is a list of [`NavGroup`](/docs/components/icon-rail#navgroup). The `id` of each item must be unique in all the groups.

## Examples

### Current section from state

Pass `value` and `onValueChange`. The item with the same `id` as `value` is the current item. The demo at the top of the page uses this.

```tsx
const [section, setSection] = React.useState('profile')

<InnerMenu title="Settings" groups={groups} value={section} onValueChange={setSection} />
```

### Current section from the router

Give an `href` to each item. Set `active` on the item of the current page.

```tsx
const groups: NavGroup[] = [
  {
    id: 'project',
    items: [
      { id: 'overview', label: 'Overview', href: '/project', active: pathname === '/project' },
      { id: 'members', label: 'Members', href: '/project/members', active: pathname === '/project/members' },
      { id: 'docs', label: 'Documentation', href: 'https://example.com/docs', external: true },
    ],
  },
]
```

### Badges and disabled items

A `badge` shows at the end of the row. The user cannot select a `disabled` item. A long label stays on one line.

```tsx
import * as React from 'react'
import { Badge, InnerMenu, type NavGroup } from 'ferry-ui'

const GROUPS: NavGroup[] = [
  {
    id: 'reports',
    label: 'Reports',
    items: [
      { id: 'revenue', label: 'Weekly revenue by region and sales channel' },
      { id: 'invoices', label: 'Invoices', badge: <Badge variant="info">12</Badge> },
      { id: 'forecast', label: 'Forecast', badge: <Badge variant="warning">Beta</Badge> },
      { id: 'exports', label: 'Scheduled exports', disabled: true },
    ],
  },
]

export default function InnerMenuStates() {
  const [section, setSection] = React.useState('revenue')

  return (
    <div className="flex h-dvh flex-col bg-background md:flex-row">
      <InnerMenu title="Analytics" groups={GROUPS} value={section} onValueChange={setSection} />
      <div className="min-w-0 flex-1 bg-dot-grid" />
    </div>
  )
}
```

### Header and footer

`header` shows between the title and the groups. `footer` shows below the groups. The row of tabs does not show these two slots.

```tsx
import * as React from 'react'
import { Badge, Button, InnerMenu, toast, type NavGroup } from 'ferry-ui'
import { Plug, Settings2, Users } from 'lucide-react'

const GROUPS: NavGroup[] = [
  {
    id: 'project',
    items: [
      { id: 'general', label: 'General', icon: <Settings2 /> },
      { id: 'members', label: 'Members', icon: <Users /> },
      { id: 'integrations', label: 'Integrations', icon: <Plug /> },
    ],
  },
]

export default function InnerMenuHeaderFooter() {
  const [section, setSection] = React.useState('general')

  return (
    <div className="flex h-dvh flex-col bg-background md:flex-row">
      <InnerMenu
        title="Project settings"
        groups={GROUPS}
        value={section}
        onValueChange={setSection}
        header={
          <div className="flex items-center justify-between gap-2 px-3">
            <span className="truncate text-sm text-foreground">Billing portal</span>
            <Badge font="mono">Pro</Badge>
          </div>
        }
        footer={
          <div className="flex flex-col items-start gap-2 rounded-lg border border-dashed p-3">
            <p className="text-[13px] text-foreground-light">Do you have a question about a setting?</p>
            <Button size="tiny" onClick={() => toast('The support form opens here')}>
              Contact support
            </Button>
          </div>
        }
      />
      <div className="min-w-0 flex-1 bg-dot-grid" />
    </div>
  )
}
```

### Side menu on a phone

Set `mobileTabs` to `false`. The side menu then shows at each width.

```tsx
import * as React from 'react'
import { InnerMenu, type NavGroup } from 'ferry-ui'

const GROUPS: NavGroup[] = [
  {
    id: 'account',
    items: [
      { id: 'profile', label: 'Profile' },
      { id: 'notifications', label: 'Notifications' },
      { id: 'security', label: 'Security' },
    ],
  },
]

export default function InnerMenuNoTabs() {
  const [section, setSection] = React.useState('profile')

  return (
    // The container is a row at each width, because the menu stays on the side.
    <div className="flex h-dvh bg-background">
      <InnerMenu
        label="Account settings"
        groups={GROUPS}
        value={section}
        onValueChange={setSection}
        mobileTabs={false}
      />
      <div className="min-w-0 flex-1 bg-dot-grid" />
    </div>
  )
}
```

## Accessibility

- The items are in a `<nav>` element. `label` sets its name. The default is `title` when it is text, or "Section".
- The current item has `aria-current="page"`.
- An `external` item opens a new tab. `externalLabel` sets the text that screen readers get after the label. The default is "(opens in a new tab)".

## API reference

`InnerMenu` accepts only the props of this table.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `groups` (required) | `NavGroup[]` |  | Sections of the menu, separated by hairline borders. A group `label` renders as an UPPERCASE mono heading (usually omitted on the first group). Items are links when they have an `href`, buttons calling `onSelect` otherwise; `external` items open in a new tab with a ↗. Item ids must be unique across all groups (they key the mobile tab strip and match `value`). |
| `title` | `ReactNode` |  | Heading of the menu, shown in a 48px bar at the top (e.g. the name of the entity being viewed). |
| `header` | `ReactNode` |  | Custom content rendered between the title bar and the groups (a switcher, a search field, a back link). Omit `title` to make it the only header. Desktop menu only: the mobile tab strip shows the items alone. |
| `footer` | `ReactNode` |  | Content pinned under the groups (a help card, a danger-zone link). Desktop menu only. |
| `label` | `string` |  | Accessible name of the navigation landmark (the `<nav>` of the side menu and of the mobile tab strip). Defaults to `title` when it is a string, else `"Section"`. |
| `value` | `string` |  | Id of the current item, for menus that switch sections without a router (tabs-like). An item is active when its own `active` flag is `true`, or — when that flag is unset — when its `id` equals `value`. Route-driven menus usually set `active` on items instead. There is no uncontrolled `defaultValue`: the menu does not render the sections, so the state that picks the visible section (your router or your own `useState`) is the source of truth. |
| `onValueChange` | `((id: string) => void)` |  | Called with the item id when an item becomes the current section: a button, or a plain click on an internal link. Not called for `external` links, nor for Cmd/Ctrl/Shift/Alt or middle clicks (they open a new tab or window). Pair with `value`. |
| `mobileTabs` | `boolean` | `true` | Below the `md` breakpoint, replace the side menu with a horizontally scrolling tab strip that keeps the active tab in view. Defaults to `true`; set `false` to always render the side menu. |
| `linkComponent` | `LinkComponent` |  | Router-aware link used for internal items. Defaults to the nearest `LinkProvider` (a plain `<a>`). |
| `externalLabel` | `string` | `(opens in a new tab)` | Screen-reader text appended to the name of `external` items. Defaults to `"(opens in a new tab)"`; pass a translation in localized apps. |
| `className` | `string` |  | Extra classes for the side menu (`<aside>`), e.g. to change its width. |
