# Icon Rail

The side navigation of an application, with icons and labels that the user can hide.

```tsx
import * as React from 'react'
import { IconRail, type NavGroup, type NavItem } from 'ferry-ui'
import { CreditCard, FolderKanban, LayoutDashboard, 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 IconRailHero() {
  // 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', items: WORKSPACE.map(withState) },
  ]
  const current = [...MAIN, ...WORKSPACE].find((item) => item.id === page)

  return (
    <div className="flex h-dvh bg-background">
      <IconRail groups={groups} />
      <div className="flex-1 p-6">
        <h1 className="text-lg font-medium text-foreground">{current?.label}</h1>
        <p className="mt-1 text-[13px] text-foreground-light">Select an item of the rail to change the page.</p>
      </div>
    </div>
  )
}
```

The button at the bottom of the rail shows or hides the labels.

## Usage guidelines

- **For the main pages of an app.** Use the rail for 3 to 10 destinations. Each item needs an icon.
- **For wide screens.** [App Shell](/docs/components/app-shell) hides the rail below 768px. Use [Mobile Nav](/docs/components/mobile-nav) there.
- **Not for the sections of one area.** For a settings area, use [Inner Menu](/docs/components/inner-menu).
- **One navigation for all screens.** Give the same `NavGroup[]` to the rail, the phone drawer and the [Command Menu](/docs/components/command-menu).

## Anatomy

Pass `groups` for lists with a divider between them, or `items` for one list. Each item is a [`NavItem`](#navitem).

```tsx title="Anatomy"

<IconRail
  groups={groups}
  logo={<Logo />}
  footer={<IconRailItem item={item} />}
/>
```

## Examples

### Current page

Set `active` on the item of the current page. An item with `href` is a link. An item with no `href` is a button that calls `onSelect`.

```tsx
const groups: NavGroup[] = [
  {
    id: 'main',
    items: [
      { id: 'overview', label: 'Overview', icon: <LayoutDashboard />, href: '/', active: pathname === '/' },
      { id: 'projects', label: 'Projects', icon: <FolderKanban />, href: '/projects', active: pathname.startsWith('/projects') },
      { id: 'search', label: 'Search', icon: <Search />, onSelect: openSearch },
    ],
  },
]
```

### Expanded rail

A collapsed rail is 56px wide. An expanded rail is 200px wide. `defaultExpanded` expands the rail at the start. The `label` of a group then shows as a heading.

```tsx
import * as React from 'react'
import { IconRail, type NavGroup, type NavItem } from 'ferry-ui'
import { CreditCard, FolderKanban, LayoutDashboard, 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 IconRailExpanded() {
  const [page, setPage] = React.useState('projects')
  const withState = (item: NavItem): NavItem => ({
    ...item,
    active: item.id === page,
    onSelect: () => setPage(item.id),
  })
  const groups: NavGroup[] = [
    { id: 'main', items: MAIN.map(withState) },
    // The label of a group shows as a heading while the rail is expanded.
    { id: 'workspace', label: 'Workspace', items: WORKSPACE.map(withState) },
  ]

  return (
    <div className="flex h-dvh bg-background">
      <IconRail groups={groups} defaultExpanded />
      <div className="flex-1 bg-dot-grid" />
    </div>
  )
}
```

### Controlled state

To control the state, pass `expanded` and `onExpandedChange`. The rail does not save the state between visits.

```tsx
import * as React from 'react'
import { Button, IconRail, 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 IconRailControlled() {
  // Your code holds the state. Save it in the storage of your app to keep it between visits.
  const [expanded, setExpanded] = React.useState(true)

  return (
    <div className="flex h-dvh bg-background">
      <IconRail items={ITEMS} expanded={expanded} onExpandedChange={setExpanded} />
      <div className="flex flex-1 flex-col items-start gap-3 p-6">
        <p className="text-[13px] text-foreground-light">The rail is {expanded ? 'expanded' : 'collapsed'}.</p>
        <Button onClick={() => setExpanded((value) => !value)}>{expanded ? 'Collapse the rail' : 'Expand the rail'}</Button>
      </div>
    </div>
  )
}
```

### Logo and footer

`logo` is the slot above the items. `footer` is the slot below the items. Use `IconRailItem` in the footer for rows with the look of the items. In a slot, `useIconRail()` gives the state of the rail.

```tsx
import * as React from 'react'
import { IconRail, IconRailItem, useIconRail, type NavItem } from 'ferry-ui'
import { FolderKanban, Keyboard, LayoutDashboard, LifeBuoy, 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 /> },
]

/** A square mark. It shows the name of the product when the rail is expanded. */
function Wordmark() {
  const rail = useIconRail()
  return (
    <span className="flex h-9 items-center gap-2 px-[9px] text-sm font-medium text-foreground">
      <span className="size-[18px] shrink-0 rounded-sm bg-brand" aria-hidden="true" />
      {rail?.expanded && 'Acme'}
    </span>
  )
}

export default function IconRailSlots() {
  const [message, setMessage] = React.useState('Select an item of the footer.')

  return (
    <div className="flex h-dvh bg-background">
      <IconRail
        items={ITEMS}
        defaultExpanded
        logo={<Wordmark />}
        footer={
          <>
            <IconRailItem
              item={{ id: 'help', label: 'Help center', icon: <LifeBuoy />, onSelect: () => setMessage('Help center') }}
            />
            <IconRailItem
              item={{ id: 'shortcuts', label: 'Shortcuts', icon: <Keyboard />, onSelect: () => setMessage('Shortcuts') }}
            />
          </>
        }
      />
      <p className="flex-1 p-6 text-[13px] text-foreground-light">{message}</p>
    </div>
  )
}
```

### Badges and disabled items

A `badge` shows after the label. On a collapsed rail, the badge becomes a dot. The user cannot select a `disabled` item. `collapsible={false}` hides the button at the bottom of the two rails.

```tsx
import { Badge, IconRail, type NavItem } from 'ferry-ui'
import { CreditCard, FolderKanban, Inbox, LayoutDashboard } from 'lucide-react'

const ITEMS: NavItem[] = [
  { id: 'overview', label: 'Overview', icon: <LayoutDashboard />, active: true },
  {
    id: 'inbox',
    label: 'Inbox',
    icon: <Inbox />,
    badge: (
      <Badge variant="primary" font="mono">
        12
      </Badge>
    ),
  },
  { id: 'projects', label: 'Projects', icon: <FolderKanban /> },
  { id: 'billing', label: 'Billing', icon: <CreditCard />, disabled: true },
]

export default function IconRailBadges() {
  return (
    <div className="flex h-dvh bg-background">
      {/* Two rails on one page: each one needs its own name. */}
      <IconRail items={ITEMS} collapsible={false} aria-label="Collapsed rail" />
      <IconRail items={ITEMS} collapsible={false} defaultExpanded aria-label="Expanded rail" />
      <div className="flex-1 bg-dot-grid" />
    </div>
  )
}
```

## Accessibility

- The rail is a `<nav>` element. `aria-label` sets its name. The default is "Main".
- On a collapsed rail, the label of an item shows in a tooltip. The label stays the accessible name. The tooltip needs a `TooltipProvider` above the rail.
- An `external` item opens a new tab. Screen readers get the `external` text after the label.
- `labels` sets the texts of the rail. Use it to translate them.

| Key of `labels` | Default text |
| --- | --- |
| `expand` | "Expand menu" |
| `collapse` | "Collapse menu" |
| `collapseText` | "Collapse" |
| `external` | "(opens in a new tab)" |

## API reference

`IconRail` also accepts each attribute of the `<nav>` element.

### IconRail

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `groups` | `NavGroup[]` |  | Navigation groups, separated by thin dividers. A group `label` shows as a mono heading while expanded (and names the list for screen readers). Takes precedence over `items`. |
| `items` | `NavItem[]` |  | Shorthand for a single group of items (no dividers). Ignored when `groups` is set. |
| `expanded` | `boolean` |  | Controlled expanded state (labels visible, 200px wide). Pair with `onExpandedChange`. |
| `defaultExpanded` | `boolean` | `false` | Initial expanded state when uncontrolled. Defaults to `false` (56px, icons only). |
| `onExpandedChange` | `((expanded: boolean) => void)` |  | Called when the toggle expands or collapses the rail. Persist the value yourself if needed. |
| `collapsible` | `boolean` | `true` | Shows the expand / collapse toggle at the bottom. Defaults to `true`. |
| `logo` | `ReactNode` |  | Top slot, above the items (brand mark, workspace switcher). It is 40px wide while collapsed: render a square mark, and use `useIconRail()` to show more when expanded. |
| `footer` | `ReactNode` |  | Bottom slot, pinned above the toggle: secondary rows (help, docs, settings) as `IconRailItem`s, or an account avatar. |
| `linkComponent` | `LinkComponent` |  | Router-aware link used by every row. Defaults to the nearest `LinkProvider` component, else a plain `<a>`. |
| `labels` | `IconRailLabels` |  | Built-in texts: the expand / collapse toggle and the screen-reader suffix of external items. |
| `aria-label` | `string` |  | Accessible name of the `<nav>` landmark. Defaults to `"Main"`; change it when a page has several navs. |

### IconRailItem

Use `IconRailItem` only in an `IconRail`.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `item` (required) | `NavItem` |  | The destination or action to render (label, icon, href / onSelect, active, badge…). |
| `linkComponent` | `LinkComponent` |  | Router-aware link for this item. Defaults to the rail's `linkComponent`, then the nearest `LinkProvider`. Not used for `external` items (they render a plain `<a target="_blank">`). |
| `className` | `string` |  | Extra classes for the row. |

### useIconRail

`useIconRail()` reads the state of the nearest rail. Outside a rail, it returns `null`.

| Value | Type | Role |
| --- | --- | --- |
| `expanded` | `boolean` | Tells if the rail shows the labels. |
| `setExpanded` | `(expanded: boolean) => void` | Expands or collapses the rail. |

### NavItem

One destination or one action. Icon Rail, Mobile Nav, Inner Menu and Command Menu use this type.

| Field | Type | Role |
| --- | --- | --- |
| `id` | `string` | A stable key. Required. |
| `label` | `string` | The visible text and the accessible name. Required. |
| `icon` | `ReactNode` | A line icon. |
| `href` | `string` | The destination. The link component of the app renders it. |
| `onSelect` | `() => void` | Runs when the user selects the item. |
| `active` | `boolean` | Marks the current page. |
| `external` | `boolean` | Opens `href` in a new tab. The link component does not render it. |
| `badge` | `ReactNode` | A count or a tag after the label. |
| `disabled` | `boolean` | Dims the item. The user cannot select it. |

### NavGroup

A group of items with an optional heading.

| Field | Type | Role |
| --- | --- | --- |
| `id` | `string` | A stable key. Required. |
| `label` | `string` | The heading of the group. |
| `items` | `NavItem[]` | The items of the group, in order. Required. |
