A floating rail of grouped links that collapses to icons and becomes a drawer on phones.
Groups and items
A float rail 16px inside the screen edges, with the brand at the top, a search action, labelled groups of links, and an account row ruled off at the bottom. Put it inside an App shell, which owns its collapsed and drawer states.
SidebarGroup: names its list with its label, a small uppercase line with a dot.SidebarItem: a link with an icon, a label and an optional count at the end.- Current page: shown as an ink pill, and its group's dot lights up.
Example
Example
Home
Spending is on track this month.
import { AppShell } from '@snapshot-ui/react';
import type { ReactElement } from 'react';
import { ShellHeader, ShellNav, ShellPage } from './shell-nav';
export default function SidebarGroups(): ReactElement {
return (
<div className="stage-shell">
<AppShell sidebar={<ShellNav label="Main, groups" />} header={<ShellHeader />}>
<ShellPage />
</AppShell>
</div>
);
}Collapsed
Collapsed, the rail is a 72px column of icons. Each name shows as a tooltip on hover and keyboard focus. A count becomes a dot on the icon's corner, with its number and words on the tooltip's second line. Every item stays a tab stop, and the labels are hidden from view, not removed, so no name changes.
Example
Example
Home
Spending is on track this month.
import { AppShell } from '@snapshot-ui/react';
import type { ReactElement } from 'react';
import { ShellHeader, ShellNav, ShellPage } from './shell-nav';
export default function SidebarCollapsed(): ReactElement {
return (
<div className="stage-shell">
<AppShell defaultSidebarCollapsed sidebar={<ShellNav label="Main, icons" />} header={<ShellHeader />}>
<ShellPage />
</AppShell>
</div>
);
}The drawer
Below 1024px the rail is hidden. SidebarTrigger, an outline menu button for the page header, opens the same rail as a drawer from the start edge. The drawer always shows the labels and opens with focus on the current page. Pressing any item closes it, including the current one. This example opens the real drawer from a button, at any width.
Example
Example
Home
The drawer is the rail a phone sees, here at any width.
'use client';
import { AppShell, Button, Card, CardBody, useAppShell } from '@snapshot-ui/react';
import type { ReactElement } from 'react';
import { ShellHeader, ShellNav } from './shell-nav';
/** The button spans the card's foot, so it stops at the card's corner inset and keeps its own shape. */
const CLEAR_OF_FOOT = { marginBlockEnd: 'calc(var(--holder-corner-inset) - var(--holder-pad))' };
/** Opens the drawer at any width, as the header's menu button does below 1024px. */
function OpenTheMenu(): ReactElement {
const { openDrawer } = useAppShell();
return (
<Button onPress={openDrawer} style={CLEAR_OF_FOOT}>
Open the menu
</Button>
);
}
export default function SidebarDrawer(): ReactElement {
return (
<div className="stage-shell">
{/* The page's button is the one way in here, so the header draws no menu button of its own. */}
<AppShell sidebar={<ShellNav label="Main, drawer" />} header={<ShellHeader hasTrigger={false} />}>
<div style={{ padding: '0 var(--spacing-inset) var(--spacing-inset)' }}>
<Card>
<CardBody>
<p style={{ margin: '0 0 16px' }}>The drawer is the rail a phone sees, here at any width.</p>
<OpenTheMenu />
</CardBody>
</Card>
</div>
</AppShell>
</div>
);
}Long labels
A long label wraps onto a second line, with the icon beside the first. It is never truncated, since that would hide which page the item opens.
Example
Example
Home
Spending is on track this month.
import { AppShell } from '@snapshot-ui/react';
import type { ReactElement } from 'react';
import { ShellHeader, ShellNav, ShellPage } from './shell-nav';
export default function SidebarLongLabels(): ReactElement {
return (
<div className="stage-shell">
<AppShell sidebar={<ShellNav label="Main, long labels" isLong />} header={<ShellHeader />}>
<ShellPage />
</AppShell>
</div>
);
}The current page
The library cannot see the router, so the app marks the current page. Read usePathname() in a client component and pass isCurrent to the item and its group. There is no built-in prefix match: whether /budgets/2026 makes Budgets current is the app's rule.
'use client';
import { SidebarGroup, SidebarItem } from '@snapshot-ui/react';
import { ChartColumn, PiggyBank } from 'lucide-react';
import { usePathname } from 'next/navigation';
export function InsightsGroup() {
const pathname = usePathname();
return (
<SidebarGroup label="Insights" isCurrent={['/reports', '/budgets'].includes(pathname)}>
<SidebarItem href="/reports" icon={<ChartColumn />} isCurrent={pathname === '/reports'}>
Reports
</SidebarItem>
<SidebarItem href="/budgets" icon={<PiggyBank />} isCurrent={pathname === '/budgets'}>
Budgets
</SidebarItem>
</SidebarGroup>
);
}Slots
Each slot renders only when given.
brand: aSidebarBrand, the mark and name as a link home. Collapsed, it keeps the mark, with the name as a tooltip.search: aSidebarSearch, a pill that opens the app's search inonPress. Collapsed, it keeps its icon, with the name as a tooltip.footer: anything, such as the signed-in account. Collapsed, it is centered. ReaduseSidebar().isCollapsedto show the account as an avatar alone.
While the drawer is open, the slots render twice, once in the rail and once in the drawer. Generate any id inside a slot with useId, never a fixed string, and read useSidebar().inDrawer to tell the copies apart. The same applies to SidebarGroup: an id you pass is repeated in the drawer copy, so leave the id to the library.
Usage
Sidebar and its parts are client components, and a server component can render them. Put the Sidebar in the shell's sidebar slot and a SidebarTrigger in its header.
- Search shortcut:
shortcuttakes keys separated by spaces, such as"Mod K", shown as a key chip. After the page loads,Modbecomes Cmd on Apple platforms and Ctrl elsewhere. The server's HTML says Mod. - Search name: the chip is part of the button's name in both states, such as "Search or jump to, Ctrl K".
- Counts: an item's
countcomes with itscountLabel, so the item reads "Transactions, 12 to review". - Links: items and the brand are React Aria links.
hrefworks before any script runs, aRouterProviderroutes every press as Routing shows, andrenderdraws a router's own link for one item.
Props
On Sidebar, className and ref go on the rail's root. The drawer copy also gets the class, but the id and other attributes stay on the rail. Each part takes className and ref on its own element, with the class merged after the library's.
aria-label string | A name for assistive technology. Give it, or |
|---|---|
aria-labelledby string | The id of an element that holds the name, such as a label shown elsewhere. |
brand ReactNode | The brand row at the top: a SidebarBrand, or anything. |
children ReactNode | SidebarGroup elements. |
className string | |
closeLabel string | The drawer's close button. Default: 'Close menu' |
collapseLabel string | Default: 'Collapse the menu' |
drawerLabel string | The drawer's accessible name. Default: 'Menu' |
expandLabel string | Default: 'Expand the menu' |
footer ReactNode | The account row at the bottom, ruled off from the list. The app reads |
search ReactNode | The search action under the brand: a SidebarSearch, or anything. |
children ReactNode | SidebarItem elements. |
|---|---|
className string | |
isCurrent boolean | Whether the group holds the current page: lights the dot and turns the label ink. |
label ReactNode | The group's label, drawn as an overline with a dot, and the list's accessible name. |
children ReactNode | The label. It is the accessible name, and it wraps when long. |
|---|---|
className string | ((values: LinkRenderProps & { defaultClassName: string | undefined; }) => string) | |
count string | number | |
countLabel string | |
href string | |
icon ReactNode | The icon, 20px, hidden from assistive technology. |
isCurrent boolean | Whether this is the page shown: sets |
onPress ((e: PressEvent) => void) | Handler that is called when the press is released over the target. |
routerOptions undefined | Options for the configured client side router. |
children ReactNode | The name, shown beside the mark and hidden from view when collapsed. It is the accessible name. |
|---|---|
className string | |
href string | |
mark ReactNode | The mark, shown in both states. A 24px box, hidden from assistive technology. |
children ReactNode | The label, such as "Search or jump to". |
|---|---|
className string | |
onPress ((e: PressEvent) => void) | Handler that is called when the press is released over the target. |
shortcut string | Keys separated by spaces, such as "Mod K", shown as a key chip and in the collapsed tooltip. |
className string | |
|---|---|
label string | The accessible name. Default: 'Open the menu' |
onPress ((e: PressEvent) => void) | Handler that is called when the press is released over the target. |
variant IconButtonVariant | The IconButton look. Default: 'outline' |
Accessibility
The rail is a <nav> named by its aria-label. Each group's list is named by the group's label, which is a paragraph, not a heading. The current page has aria-current="page". Tab walks the rail in order, with no arrow keys to learn.
- Names never depend on state: a collapsed label stays in the name, and a count's words are always in it.
- The collapse button names what it will do and says whether the rail is expanded.
- The drawer opens with focus on the current page, keeps Tab inside, hides the page behind it, and returns focus to the menu button when it closes.
| TabShift+Tab | Walk the brand, the collapse button, the search, every item in order, then the footer. Collapsed, every icon is still a stop. |
|---|---|
| Enter | Follows an item, or opens the search. Space does nothing on a link. |
| EnterSpace | On the collapse button, folds or unfolds the rail. |
| Escape | Closes an open tooltip. In the drawer, closes the drawer and returns focus to the menu button. |
Design rules
What keeps the rail in the system.
Keep to about four short groups.
Show a count only for things that need review.
Label an item with the name of the page it opens.
Never mark an item current by a prefix match without a rule for it.
Never truncate a label. Let it wrap.
Never hide a count's words. The number alone is not a name.