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

Skip to content

Home

Spending is on track this month.

Follows the page theme and corners
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

Skip to content

Home

Spending is on track this month.

Follows the page theme and corners
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

Skip to content

Home

The drawer is the rail a phone sees, here at any width.

Follows the page theme and corners
'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

Skip to content

Home

Spending is on track this month.

Follows the page theme and corners
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: a SidebarBrand, the mark and name as a link home. Collapsed, it keeps the mark, with the name as a tooltip.
  • search: a SidebarSearch, a pill that opens the app's search in onPress. Collapsed, it keeps its icon, with the name as a tooltip.
  • footer: anything, such as the signed-in account. Collapsed, it is centered. Read useSidebar().isCollapsed to 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: shortcut takes keys separated by spaces, such as "Mod K", shown as a key chip. After the page loads, Mod becomes 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 count comes with its countLabel, so the item reads "Transactions, 12 to review".
  • Links: items and the brand are React Aria links. href works before any script runs, a RouterProvider routes every press as Routing shows, and render draws 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, where no label or text is shown.

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 useSidebar().isCollapsed to draw it icon-only.

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 aria-current="page" and draws the ink pill.

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. Mod becomes the platform's modifier after mount.

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+TabWalk the brand, the collapse button, the search, every item in order, then the footer. Collapsed, every icon is still a stop.
EnterFollows an item, or opens the search. Space does nothing on a link.
EnterSpaceOn the collapse button, folds or unfolds the rail.
EscapeCloses 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.

esc

↑ ↓ to move↵ to open