Skip to main content

Navigation

NavigationRail

The vertical bar of the app's top-level pages on the start edge of a wide screen, as in a chat or mail app or an editor's activity bar: each item an icon over a short label, the current one with its currentIcon, a bar on the rail's edge and aria-current="page". An item can carry a count, capped at "99+" on the badge and spoken in full, be a button for an app that switches views without its router (onClick without href), and sit after a { type: "separator" }. A header (your app's mark) sits above the items and a footer (settings, the account) below them.

Chat

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.3.10

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/navigation-rail#v1.3.10

Or, with the @ouro namespace set up:

npx shadcn@latest add @ouro/ouro-base @ouro/navigation-rail

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/navigation-rail#v1.3.10 ourostack/ouro-ui/navigation-rail-tests#v1.3.10
import { NavigationRail } from "@/components/ouro/navigation-rail/navigation-rail";

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

Example

<NavigationRail
  label="Main"
  currentPath={pathname}
  header={<AppMark />}
  items={[
    { label: "Activity", href: "/activity", icon: <BellIcon />, currentIcon: <BellFilledIcon />, count: 3 },
    { label: "Chat", href: "/chat", icon: <ChatIcon />, currentIcon: <ChatFilledIcon /> },
    { label: "Calendar", href: "/calendar", icon: <CalendarIcon /> },
    { type: "separator" },
    { label: "Files", href: "/files", icon: <FolderIcon /> },
  ]}
  footer={<SettingsButton />}
/>

Usage

The rail is one tab stop: Up and Down move between its items, wrapping, and Home and End go to the ends, while Tab moves on to the footer and the page. Items that do not fit the rail's height move, from the last, into a More menu at its end; the current item stays on the rail. Labels wrap to two lines between words; one still too long ends in an ellipsis and shows whole in a Tooltip, as every label does with hideLabels. The current item's bar grows in from its middle; with reduced motion it only fades.

Links render through OuroProvider's linkComponent, so your router navigates. The rail fills its container's height and draws no surface of its own, so it sits on your app's frame.

Use instead when

  • Use TabBar instead for the bar at the bottom of a phone screen (hide the rail there with className="max-sm:hidden"), Sidebar for a column of labelled links in sections, and Navbar for a few links across the top.

Props

Type: NavigationRailProps.

className
Type: string
Layout only: placement and width, or max-sm:hidden where a TabBar takes over. Appearance classes warn in development.
countLabel
Type: string
Default: "{count} new"
What screen readers hear after an item's label when it has a count, with {count} for the number: "Activity, 5 new".
currentPath
Type: string
The path of the page being shown, from your router (useLocation().pathname, or usePathname() in Next.js). The item whose href or paths match it, or else the longest one above it (/chat for /chat/12), gets aria-current="page" and the current look.
footer
Type: ReactNode
Below the items, out of their way: a settings Button or an account Menu.
header
Type: ReactNode
Above the items: your app's mark, or a workspace Menu.
hideLabels
Type: boolean
Default: false
Shows the icons alone, in a 56px rail; each item's label names it for screen readers and shows in a Tooltip on hover and keyboard focus.
items (required)
Type: readonly NavigationRailEntry[]
The items, in order, with { type: "separator" } between groups. Those that do not fit the rail's height move, from the last, into a More menu at its end; the current item stays on the rail.
label (required)
Type: string
Names the navigation for screen readers ("Main"). It is not shown.
moreLabel
Type: string
Default: "More"
The label of the button that holds the items that do not fit.
ref
Type: Ref<HTMLElement>
Reaches the <nav>.

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 rail is items, header and footer.
  • collapsed: Not a prop. hideLabels shows the icons alone.
  • links: Not a prop. Ouro UI uses items: <NavigationRail label="Main" items={[{ label, href, icon }]} />.
  • onSelect: Not a prop. Give the items their own onClick.
  • pathname: Not a prop. Ouro UI uses currentPath: <NavigationRail currentPath={pathname}>.
  • selectedId: Not a prop. Mark the shown view's item current: true, and switch views in each item's onClick.
  • value: Not a prop. The current item follows currentPath, or an item's current.

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

A button in a NavigationRail, for an app that switches views without its router: give the shown view's item current.

interface NavigationRailButtonItem extends NavigationRailItemBase {
  /** What the item does: show its view. */
  onClick: (event: MouseEvent<HTMLElement>) => void;
  /** Not with `onClick` alone. Give a link `href`; a button has none. */
  href?: never;
  /** Not on a button. A button is current by its `current` flag. */
  paths?: never;
}

One entry in a NavigationRail's items: an item, or a separator between groups.

type NavigationRailEntry = NavigationRailItem | NavigationRailSeparator;

One item of a NavigationRail: a link (href) or a button (onClick).

type NavigationRailItem = NavigationRailLinkItem | NavigationRailButtonItem;

A link in a NavigationRail: one of the app's top-level pages.

interface NavigationRailLinkItem extends NavigationRailItemBase {
  /** Where it goes. Internal paths go through `OuroProvider`'s `linkComponent` (your router's link). */
  href: string;
  /**
   * Other paths whose pages belong to this item, so it stays current there: a Chat item that
   * also owns `/people`. Each matches its own path and the pages under it.
   */
  paths?: readonly string[] | undefined;
  /** Called when the link is followed, before your router navigates. */
  onClick?: ((event: MouseEvent<HTMLElement>) => void) | undefined;
}

A short rule between groups of items, as Menu's { type: "separator" } is.

interface NavigationRailSeparator {
  type: "separator";
}