Skip to content
Tier

Nav rail

Pro

A narrow rail of the app's main places, each an icon above its word (never icon-only), for AppLayout's sidebar, in either frame; with frame="app" it is the L's vertical arm, under the brand. The current place is a tinted rounded block marked as the current page (in forced colours, a Highlight border, so it isn't carried by a background). Every place is at least 44 px tall.

A count sits on the icon's top-end corner only when the place has one: filled in the accent only when it is urgent (things waiting on the person), quiet otherwise, and always read in the place's name ("Approvals, 3 waiting"), so it is never colour alone. groups split the places with a divider; pinned places sit at the foot (a System or Settings place). A place with an href is a link (a plain click calls onNavigate, for your router; the app's link render draws it when one is set), else a button, as AppSidebar's places are.

In AppLayout's sheet (a narrow frame) the same places draw as rows, icon beside word, with the group labels shown. On a phone, navRailTabs gives a TabBar the first four from the same list. The rail is 5rem wide; a brand may set --cd-nav-rail-width. With the Pro motion-drawings item's --cd-draw-highlight, a box travels behind the place under the pointer (GlideHighlight).

Markdown
Version 5.2.0· other versions

This page is for 5.2.0, the current release. An earlier release’s page is the Markdown that release published, so the docs match the version you have installed (candor-lock.json records it).

Not in the docs of 1.3.0, 1.4.0, 1.5.0, 1.5.1, 1.6.0, 1.7.0, 1.8.0, 1.8.1, 1.9.0, 1.9.1, 2.0.0, 2.1.0, 2.2.0, 2.3.0, 2.4.0, 2.5.0, 2.5.1, 2.6.0, 2.6.1, 2.6.2, 2.7.0, 2.8.0, 2.9.0, 2.10.0, 3.0.0, 4.0.0, 4.0.1, 4.0.2, 4.1.0, 4.2.0 and 4.3.0: the page came later, or had another name then.

Docs pages weren’t kept for 1.0.0, 1.0.1, 1.1.0, 1.1.1, 1.2.0 and 1.2.1: they are kept from 1.3.0 on, and before that only llms.txt was published.

Preview

Loading the preview

Install

With the Candor CLI

First time? Get started sets up the registry and theme. Or with shadcn directly: npx shadcn@latest add @candor/nav-rail

Brings in: app-layout, app-sidebar, glide-highlight, link, locale, motion, tab-bar, tokens, utils

Usage

TSX · The example above
"use client"

import * as React from "react"
import { Activity, Bell, Home, MessagesSquare, Settings, ShieldCheck, SquareKanban, Workflow } from "lucide-react"
import { AppLayout } from "@/components/candor/app-layout"
import { NavRail, useNavRailTabs, type NavRailGroup } from "@/components/candor/nav-rail"
import { TabBar } from "@/components/candor/tab-bar"

const groups: NavRailGroup[] = [
  {
    id: "work",
    label: "Work",
    places: [
      { id: "home", label: "Home", Icon: Home },
      { id: "chat", label: "Chat", Icon: MessagesSquare },
      { id: "board", label: "Board", Icon: SquareKanban },
      // An urgent count is filled and read in the name: "Approvals, 3 waiting".
      { id: "approvals", label: "Approvals", Icon: ShieldCheck, count: 3, urgent: true },
    ],
  },
  {
    id: "watch",
    label: "Watch",
    places: [
      { id: "flows", label: "Flows", Icon: Workflow },
      { id: "activity", label: "Activity", Icon: Activity, count: 8 },
      { id: "alerts", label: "Alerts", Icon: Bell },
    ],
  },
]
const pinned = [{ id: "settings", label: "Settings", Icon: Settings }]
const all = [...groups.flatMap((g) => g.places), ...pinned]

export default function Example() {
  const [place, setPlace] = React.useState("home")
  // The phone's bar takes the places the app picks, from the same list (or the first four without ids); More opens the
  // sheet with the rest. A short rail scrolls its places, the pinned foot kept in view.
  const { destinations, more } = useNavRailTabs(all, { ids: ["home", "chat", "approvals", "alerts"] })
  // One frame at a time: a page has one main. The phone's frame is narrower than tabBarAt, so the bar shows.
  const [size, setSize] = React.useState<"wide" | "phone">("wide")
  return (
    <div className="flex flex-col gap-3">
      <label className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
        Frame
        <select value={size} onChange={(e) => setSize(e.target.value as "wide" | "phone")} className="max-w-full min-w-0 rounded-md border bg-background px-2 py-1 text-foreground">
          <option value="wide">Wide: the rail</option>
          <option value="phone">Phone: the tab bar, the rest behind More</option>
        </select>
      </label>
      {size === "wide" ? (
        <div className="h-[520px] w-full max-w-[420px] overflow-hidden rounded-lg border">
          <AppLayout defaultWidth={420} sidebarAt={400} sidebar={<NavRail places={[]} groups={groups} pinned={pinned} activeId={place} onNavigate={setPlace} />}>
            <div className="p-4 text-sm">
              <h2 className="font-semibold capitalize">{place}</h2>
            </div>
          </AppLayout>
        </div>
      ) : (
        <div className="h-[520px] w-[340px] overflow-hidden rounded-lg border">
          <AppLayout
            defaultWidth={340}
            sidebar={<NavRail places={[]} groups={groups} pinned={pinned} activeId={place} onNavigate={setPlace} />}
            tabBar={<TabBar destinations={destinations} activeId={place} onNavigate={setPlace} more={more} />}
          >
            <div className="p-4 text-sm">
              <h2 className="font-semibold capitalize">{place}</h2>
            </div>
          </AppLayout>
        </div>
      )}
    </div>
  )
}

<NavRail>

PropTypeDefaultDescription
placesrequiredNavItem[]—The places, in order. Left out of the drawing when `groups` is given.
groupsNavRailGroup[] | undefined—The places in groups, drawn in place of `places`, a divider between groups.
pinnedNavItem[] | undefined—Places pinned to the foot of the rail, below the rest.
activeIdstring | undefined—The id of the place the person is in, marked as the current page, when you control it.
defaultActiveIdstring | undefined—The place to start in, when the rail keeps it itself: with this and no `activeId`, a place chosen in this page (not opened in a new tab) becomes the current one, and `onNavigate` is still called. Without either, none is marked.
onNavigate((id: string) => void) | undefined—Called with a place's id when it's chosen. For a place with an `href`, a plain click calls this instead of following the link; other clicks open the link as usual.
labelstring | undefined—The navigation's accessible name. Defaults to "Main".
classNamestring | undefined—Classes for the rail, merged with Candor's.

Functions

navRailTabs(places: NavItem[], { ids, max = 4, pinned, waiting = (label: string, count: number) => `${label}, ${count} waiting`, }: { /** The ids of the places for the bar, in the bar's order (a pinned place's id included); an id with no place is skipped. */ ids?: string[] | undefined; /** * The rail's `pinned` places (Settings, Help). Not on the bar unless `ids` names one, but counted for `more`: a * pinned place left off the bar makes `more` true, so it can still be reached on a phone. */ pinned?: NavItem[] | undefined; /** Without `ids`, how many of the first places go on the bar. Defaults to 4. */ max?: number | undefined; /** The name of a destination with an urgent count. */ waiting?: ((label: string, count: number) => string) | undefined; } = {})

A phone's TabBar destinations from the same places the rail draws. With ids, the places the app chose for the phone, in that order (a place the rail doesn't show can be one: pass it in places too, and leave it out of the rail's); without, the first max (four by default). more is true when places are left out of the bar, the rail's pinned ones included when they are passed as pinned (a pinned Settings that isn't on the bar can still be reached): give it to TabBar's more, whose button opens the sidebar sheet with the rest, or leave More off. An urgent count becomes the destination's badge, read by badgeLabel in the rail's words ("Approvals, 3 waiting"); a quiet count is left off the bar, which has no quiet badge. waiting makes that name (it gets the place's label and its count); it defaults to the English "{label}, {count} waiting" with the count unformatted. In a component, use useNavRailTabs, which makes the name from LocaleProvider (navRail.waiting, the count through its number formatter), as the rail does.

useNavRailTabs(places: NavItem[], options: Parameters<typeof navRailTabs>[1] = {})

navRailTabs in a component: the same destinations, each counted name made as the rail makes its own, from LocaleProvider's navRail.waiting with the count through its number formatter, so the bar and the rail read alike in every language. waiting, given, still wins.

Strings

The fixed words, in English by default. Change any of them under navRail in <LocaleProvider strings={{ … }}>; the table is exported as navRailStrings.

KeyEnglish
mainMain
waiting{label}, {count} waiting
counted{label}, {count}

Motion

MomentFamilyWhat it does
tinttintA place's background and text colour change when the pointer is over it and for the current page, and a ring fades in on keyboard focus. The place that is current when the rail first appears is drawn current at once.

Set with --cd-motion-nav-rail-<moment>-* in your brand file; the families are in the design reference.

Types

NavRailGroup
A group of places in the rail, with a divider before it; its label is read by screen readers, and shown in a sheet.
{ /** A key for the group. */ id: string; /** The group's name: visually hidden in the rail, shown above its places in a sheet. */ label: string; /** The group's places, in order. */ places: NavItem[]; }