Layout
Mobile Nav
The navigation drawer of an application on a phone.
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.
mobileNav slot of App Shell. On wide screens, Icon Rail shows the navigation.NavGroup[] to the rail and to the drawer. See NavItem.groups or items.Import the parts and put them together.
import { Button, MobileNav, MobileNavSection, MobileNavTrigger } from 'ferry-ui'
<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. |
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 has its own menu button.
<AppShell
topBar={
<header className="flex h-12 items-center border-b px-2">
<MobileNavTrigger />
</header>
}
mobileNav={<MobileNav groups={groups} title="Acme" />}
>
{children}
</AppShell>Outside a shell, pass a trigger or control the state. To control the state, pass open and onOpenChange.
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>
)
}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.
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>
)
}By default, the drawer closes when the user selects an item. Set closeOnSelect to false to keep it open.
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>
)
}side="right" moves the drawer to the right edge of the screen.
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>
)
}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".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)" |
MobileNav accepts only the props of this table.
| Prop | Type | Default |
|---|---|---|
groups | NavGroup[] | - |
Navigation groups, separated by borders; a group | ||
items | NavItem[] | - |
Shorthand for a single group of items. Ignored when | ||
open | boolean | - |
Controlled open state. Pair with | ||
defaultOpen | boolean | false |
Initial open state when uncontrolled and outside an | ||
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 | ||
logo | ReactNode | - |
Brand mark shown before the title (about 20px). It sits inside the accessible title, so mark it | ||
title | ReactNode | Menu |
Drawer title, also its accessible name (usually the app or workspace name). Defaults to | ||
description | ReactNode | - |
Short line under the title (workspace, plan, version…). | ||
children | ReactNode | - |
Extra blocks below the groups, usually | ||
footer | ReactNode | - |
Bottom slot pinned under the scrolling content, usually a full-width sign-out | ||
showThemeToggle | boolean | false |
Shows a Light / Dark / System segmented control. By default it reads and writes the nearest | ||
theme | "light" | "dark" | "system" | - |
Controlled theme preference of the theme control. Omit it to follow the nearest | ||
onThemeChange | ((theme: ThemePreference) => void) | - |
Called with the chosen theme preference (the | ||
closeOnSelect | boolean | true |
Closes the drawer when an item is chosen. Defaults to | ||
side | "right" | "left" | left |
Edge the drawer slides in from. Defaults to | ||
linkComponent | LinkComponent | - |
Router-aware link used by every item. Defaults to the nearest | ||
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 accepts the props of Button, without children.
| Prop | Type | Default |
|---|---|---|
variant | "link" | "default" | "primary" | "outline" | "ghost" | "destructive" | "destructive-solid" | "danger" | "danger-solid" | "warning" | "dashed" | - |
Look.
| ||
size | "tiny" | "sm" | "md" | "lg" | "icon-tiny" | "icon" | "icon-md" | "icon-lg" | - |
Height. | ||
shape | "default" | "pill" | - |
Corners: | ||
asChild | boolean | - |
Style the single child element (usually an | ||
loading | boolean | - |
Shows a spinner in place of | ||
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 | ||
MobileNavSection also accepts each attribute of the <div> element.
| Prop | Type | Default |
|---|---|---|
label | ReactNode | - |
Mono heading of the section (1–3 words). | ||