Navigation

Sidebar

The column of links down the side of an app, in sections, with the current page marked aria-current="page" and a bar at the start edge, distinct from hover and press highlights. An item can carry a badge ("Inbox 12"), be a button (onClick without href), or hold one level of nested links (items). From 640px up it fills its container's height and collapses to a rail of icons with its own button, each named by a Tooltip, where an item with nested links opens them in a Menu; below 640px it is a menu button that opens the same links as a Sheet drawer. Links render through OuroProvider's linkComponent, so your router navigates.

Install

Once per app, with Tailwind CSS v4 and an @/* path alias (Ouro HX is built on Base UI, so --base base; skip -t in an existing app):

npx shadcn@latest init --base base --preset nova --yes --no-monorepo
npx shadcn@latest add ourostack/ouro-ui/ouro-base#v1.2.13

Then the component. From v0.8.0 it installs only the helpers it imports and never touches your stylesheet (Installing):

npx shadcn@latest add ourostack/ouro-ui/sidebar#v1.2.13

Or, with the @ouro namespace set up:

npx shadcn@latest add @ouro/ouro-base @ouro/sidebar

Its tests, stories and Playwright spec are optional. They need Vitest 4.1.11 or later, and from v0.8.0 never change a version your app has (Testing):

npx shadcn@latest add ourostack/ouro-ui/sidebar#v1.2.13 ourostack/ouro-ui/sidebar-tests#v1.2.13
import { Sidebar } from "@/components/ouro/sidebar/sidebar";

Paths use shadcn's default @/ alias; the CLI rewrites them to yours.

Example

<Sidebar
  label="Main"
  header={<Link href="/">Spoonjoy</Link>}
  sections={[
    { items: [{ label: "Recipes", href: "/recipes", icon: <BookIcon /> }] },
    { label: "Library", items: [{ label: "Cookbooks", href: "/cookbooks", icon: <StackIcon /> }] },
  ]}
  currentPath={pathname}
/>

Usage

The phone menu opens once JavaScript has loaded (after hydration); the links are in the server HTML from 640px up.

Put it in a SidebarLayout, which places it beside the page and in the phone's top bar.

Use instead when

  • Use Navbar instead for a handful of links across the top, and Tabs for views of one page.

Props

Type: SidebarProps.

className
Type: string
Layout only: grid placement and margins (row-span-2). Appearance classes warn in development.
closeLabel
Type: string
Default: "Close"
The label of the drawer's close button.
collapsed
Type: boolean
Whether the sidebar is collapsed, when you control it (to remember it). Pair it with onCollapsedChange.
collapseLabel
Type: string
Default: "Collapse sidebar"
The collapse button's name and text; aria-pressed says whether it is on.
collapsible
Type: boolean
Default: true
Whether the sidebar has a button that collapses it to a rail of icons, from 640px up.
currentPath
Type: string
The path of the page being shown, from your router (useLocation().pathname, or usePathname() in Next.js). The link to that path, or else the longest link above it (/recipes for /recipes/12), gets aria-current="page" and the current look.
defaultCollapsed
Type: boolean
Default: false
Whether the sidebar starts collapsed, when it keeps its own state.
footer
Type: ReactNode
Below the links: the signed-in person, an account Menu. Clipped to the rail when collapsed.
header
Type: ReactNode
Above the links: your logo, product name or a workspace Menu. Clipped to the rail when collapsed.
label (required)
Type: string
Names the navigation for screen readers ("Main"). It is not shown.
menuLabel
Type: string
Default: "Menu"
Below 640px: the name of the button that opens the sidebar as a drawer, and the drawer's title (shown when there is no header).
onCollapsedChange
Type: ((collapsed: boolean) => void)
Called with the new state when the person collapses or expands the sidebar.
ref
Type: Ref<HTMLDivElement>
Reaches the rendered element.
sections (required)
Type: readonly SidebarSection[]
The links, in groups: sections={[{ items: [...] }, { label: "Library", items: [...] }]}.

Don't use

These are not props. Passing one fails the type check with the fix in the error, and in development a console message names the fix.

  • children: Not rendered. The sidebar is sections, header and footer.
  • isCollapsed: Not a prop. Ouro UI uses collapsed with onCollapsedChange, or defaultCollapsed.
  • items: Not a prop. Links go in sections: sections={[{ items: [{ label: "Recipes", href: "/recipes" }] }]}.
  • pathname: Not a prop. Ouro UI uses currentPath: <Sidebar currentPath={pathname}>.

className is for layout only

Pass margins, width and grid placement (mt-4, w-full, col-span-2).

Appearance comes from props, not classes. In development, a class that sets appearance logs one warning per component instance, naming the prop to use instead. A class sets appearance when, after its variants (hover:, sm:) and any ! are removed, it starts with bg-, text-, border-, rounded-, shadow-, font-, ring-, from-, via-, to-, fill- or stroke-, or is border, rounded, shadow or ring, or has a dark: variant. Text wrapping, overflow and table layout classes (text-wrap, text-ellipsis, border-collapse) are layout and do not warn.

Alignment classes (text-center) warn too: set alignment on a parent element.

Types

SidebarButtonItem

A button in a Sidebar, for an action rather than a page: "New chat", "Search" opening a Command.

interface SidebarButtonItem extends SidebarItemBase {
  /** What the button does. In the phone's drawer, the drawer closes first. */
  onClick: (event: MouseEvent<HTMLElement>) => void;
  /** Not with `onClick` alone. Give a link `href`; a button has none. */
  href?: never;
  /** Not on a button. Only a link can be the current page. */
  current?: never;
  /** Not on a button. Nested links go in an item without `onClick`: `{ label: "Settings", items: [...] }`. */
  items?: never;
}

SidebarGroupItem

An item with links nested under it, one level deep: a button that shows and hides them. It starts open when it holds the current page. On the collapsed rail the button opens them in a Menu beside it.

interface SidebarGroupItem extends SidebarItemBase {
  /** The nested links and buttons. They do not nest further. */
  items: readonly (SidebarLinkItem | SidebarButtonItem)[];
  /** Not with `items`. To link the group's own page, make it the group's first item. */
  href?: never;
  /** Not with `items`; the group's button shows and hides them. */
  onClick?: never;
  /** Not on a group. One of its links is the current page. */
  current?: never;
}

SidebarItem

One item of a Sidebar: a link (href), a button (onClick), or links nested under a button (items).

type SidebarItem = SidebarLinkItem | SidebarButtonItem | SidebarGroupItem;

SidebarLinkItem

A link in a Sidebar.

interface SidebarLinkItem extends SidebarItemBase {
  /** Where it goes. Internal paths go through `OuroProvider`'s `linkComponent` (your router's link). */
  href: string;
  /**
   * Marks this link as the current page (`aria-current="page"`), when the Sidebar's
   * `currentPath` cannot tell: a link whose page lives at another path.
   */
  current?: boolean | undefined;
  /** Called when the link is followed, before your router navigates. */
  onClick?: ((event: MouseEvent<HTMLElement>) => void) | undefined;
  /** Not with `href`. Nested links go in an item without `href`: `{ label: "Settings", items: [...] }`. */
  items?: never;
}

SidebarSection

A group of links in a Sidebar, under an optional label.

interface SidebarSection {
  /** The group's name, shown above its links: "Library". */
  label?: string | undefined;
  /** The group's items, in order: links, buttons and nested links. */
  items: readonly SidebarItem[];
}