Skip to content

NavRail

Terminal window
$ npx shadcn@latest add https://lacodda.github.io/dowel/r/nav-rail.json
See it liveThe same destinations in all three shapes - press one to move it. The greyed entry is a screen that exists on the roadmap and not in the build.→

One list, three shapes, because they are the same list — and when they were three components in two products they drew the current entry three different ways.

<NavRail
label={t('nav.screens')}
items={SCREENS.map((id) => ({ id, label: t(`nav.${id}`), icon: <Icon id={id} /> }))}
activeId={screen}
render={(item) => <NavLink to={`/${item.id}`} />}
/>

The entries are your links. render takes an item and answers with the element to draw it as — a router’s NavLink, a plain <a>, whatever you navigate with — and the rail puts the clothes, the icon and the aria-current on it, the way Button and SectionNav take a render. That keeps the router out of the registry: a set that imported one would install it in a product that had chosen another. Without render an entry is a button and onSelect says which was pressed.

The three shapes. column is the desktop rail, running the full height of the window so its foot sits at the bottom because the nav reaches it. row is a strip of tabs beside a product’s name in a header. bar is the phone’s, along the bottom where the thumb already is — it carries the safe-area inset for the home indicator, without which the last row of entries sits under it with no way to scroll out.

A product usually has two of these on one page: a row at sm and up and a bar below it. That is why label is required and is not drawn — it names the landmark, so a reader meeting both is told which is which.

The current destination is announced, not only tinted. The active entry carries aria-current="page". Colour says it to nobody who cannot see it.

A destination that is not built yet is not a link. soon draws the entry greyed and renders it as a span, ignoring render — even a disabled button is still in the tab order on some browsers, and tabbing onto a screen that does not exist is a dead end. Put the version in end to say when it arrives.

Labels stay on one line. A rail is a fixed-width column, and a label that wraps makes one row taller than the rest — which in a two-language product happens to one entry and not the others.

Collapsed, the column keeps only its icons. A work open on screen wants the width, and the destinations are still needed — so collapsed draws the column as squares around the icons, at --spacing-rail-compact (56px), and each entry’s name moves into a Tooltip beside it. The tooltip’s trigger is the entry itself, so it opens on keyboard focus as well as on hover, and the name stays in the entry off the screen: a reader hears “Catalogue, current page” whether the rail is wide or narrow.

<AppShell sideWidth={collapsed ? 'var(--spacing-rail-compact)' : 'var(--spacing-rail)'} side={
<NavRail label="Screens" items={screens} activeId={screen} collapsed={collapsed} />
}>

The product passes the same collapsed to every NavRail and NavGroup it builds its rail from — one boolean in the one place the rail is assembled. A collapsed NavGroup is a short hairline, with its word kept for a reader, and an entry with no icon shows its initial rather than an empty square. The row and bar shapes have no narrow form: they are made of their names.

The narrow shapes clear the pointer target floor. A row tab and a bar cell carry target-min, so they are at least 24 CSS pixels however small the text is. A column entry is already taller than that.

Props

Read from the types in ui/nav-rail.tsx, the file a product copies - so the description of each prop is also what an editor shows over it there.

NavRail

PropTypeDefault
layoutcolumn | row | bar'column'
labelrequiredstringWhat this set of destinations is called. Not drawn - it names the landmark, so a reader meeting two navs on one page can tell which is which. A product with a rail AND a bottom bar has exactly that.
itemsrequiredreadonly NavRailItem[]The destinations, in the order they are drawn.
activeIdstringWhich destination is the open screen.
render(item: NavRailItem) => RenderProp<Record<string, unknown>>The element an entry is drawn as - render={(item) => <NavLink to={…} />}.
onSelect(id: string) => voidPressed, whatever the entry is drawn as.
collapsedbooleanfalseIcons only, with each name in a tooltip. The column shape only - a row of tabs and a bottom bar have no narrow form, because their names are what they are made of.

And every prop of React's HTMLAttributes<HTMLElement> except children, onSelect, passed on.

NavGroup

The caption over a run of entries: Library, Team, Admin.

PropTypeDefault
collapsedbooleanfalseDraw a hairline instead of the caption, to match a collapsed rail; the words stay for a screen reader.

And every prop of React's HTMLAttributes<HTMLDivElement>, passed on.

NavSpacer

Pushes everything after it to the far end of the rail.

Every prop of React's HTMLAttributes<HTMLDivElement>, passed on.

Accessibility

The examples on the stand, through axe 4.14.0 in a browser, in both themes, against WCAG 2.2 A and AA and axe's best practices: 12 rules applied and passed, and none failed. The accessibility report has every item.

History

Each release that changed ui/nav-rail.tsx - the file a product copies - newest first.

  1. v0.41.0
    • docs read every props table from the component's types ee3b159
  2. v0.39.0
    • feat let the frame go on paper, and keep a panel or a chart on one sheet 354d583
  3. v0.33.0
    • feat a collapsed rail, a centred title bar, described sections b91b8d6
  4. v0.32.0
    • fix let an icon keep the size it is given, across the set 217b558
  5. v0.29.0
    • feat one list of destinations, in the three shapes it takes a58c06d