Navigation

Nav Rail

A narrow vertical icon navigation bar that widens into a labelled column — keyboard-reachable tooltips, top/bottom zones, badges and a controllable expanded state.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { PanelLeftClose, PanelLeftOpen } from "lucide-react"
import { cn } from "@/lib/utils"

/** Collapsed rail width (px) when `width` is omitted or unusable. */
const DEFAULT_WIDTH = 60
/** Expanded rail width (px) when `expandedWidth` is omitted or unusable. */
const DEFAULT_EXPANDED_WIDTH = 224
/** Below this a 20px icon plus its focus ring stops fitting, so widths clamp up to it. */
const MIN_WIDTH = 44

export interface NavRailItem {

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/nav-rail.json

Prompt

The prompt behind this component — paste it into your AI assistant to recreate or adapt it.

Build a React + TypeScript + Tailwind "NavRail" component — a narrow vertical
icon navigation bar (VS Code activity bar / Gmail rail) that widens into a
labelled column. lucide-react supplies the two toggle glyphs; no other runtime
dependency, and no popover/tooltip library.

Contract
- export const NavRail = React.forwardRef<HTMLElement, NavRailProps> rendering a
  <nav aria-label>; merge className via cn(), spread remaining native props, and
  merge the consumer's style AFTER the computed width so width stays overridable.
  aria-label defaults to "Primary".
- NavRailItem = { id: string; label: string; icon: React.ReactNode;
  href?: string; onSelect?: (id: string) => void; badge?: string | number;
  disabled?: boolean }.
- NavRailGroup = { id: string; label?: string; align?: "start" | "end";
  items: NavRailItem[] }.
- Props: items: NavRailItem[] (the primary block, always first in the top zone),
  groups?: NavRailGroup[] (extra blocks; align: "end" pins one to the bottom of
  the rail — settings / help / account), activeId?: string,
  expanded?: boolean + defaultExpanded?: boolean (default false) +
  onExpandedChange?: (expanded: boolean) => void,
  width?: number (default 60), expandedWidth?: number (default 224),
  collapsible?: boolean (default true), header?: React.ReactNode.
- Selection is never stored: activeId is compared to item.id. Expansion is the
  only state, and only when uncontrolled — `expanded ?? internalExpanded`, with
  setState skipped entirely while `expanded` is defined so a controlled parent
  can veto the change.

Behavior
- Width: style={{ width: expanded ? expandedWidth : width }} plus
  transition-[width] (width is a real animatable property — nothing is
  transformed, so the rail never needs overflow-hidden, which would clip the
  tooltips). motion-reduce:transition-none makes it snap while still changing
  state.
- Clamp both widths: reject NaN/Infinity/non-numbers back to the defaults, floor
  them at 44px (a 20px icon plus its focus ring), and take
  max(expandedWidth, width) so a bad expandedWidth can never make "expand" shrink
  the rail.
- Element choice follows what the consumer wired: href -> real <a href>;
  otherwise onSelect -> <button type="button">; neither -> a plain <span> row
  with no hover affordance. Never nest a <button> inside an <a>.
- disabled renders a <button aria-disabled="true"> with no handler — NOT the
  native disabled attribute, because that removes the item from the Tab order and
  browsers then never fire the hover/focus that reveals its label. It keeps
  opacity-50 + cursor-not-allowed but stays focusable and tooltip-able.
- Collapsed items show only their icon, so every interactive row carries
  aria-label = label (plus ", <badge>" when a badge is present) in BOTH states —
  the accessible name is then identical whichever width the rail is at.
- Tooltip: a self-drawn absolutely-positioned bubble at left-full, revealed by
  group-hover/item AND group-focus-visible/item, so it is reachable by keyboard
  and not only by pointer. It is aria-hidden + pointer-events-none because the
  row's aria-label already carries the name — this is a visual affordance, not an
  ARIA tooltip, which is why it needs no popover primitive. It animates
  opacity + a 4px slide; in Tailwind v4 -translate-x-* writes the `translate`
  property, so the transition list must be transition-[opacity,translate] or the
  slide silently does nothing.
- Labels stay mounted in both states and clip themselves (w-0 + truncate when
  collapsed, min-w-0 flex-1 truncate when expanded) instead of unmounting: the
  icon stays centred in the rail, long labels ellipsize rather than wrapping a
  row to two lines, and assistive tech still finds the text on non-interactive
  rows.
- Badges: expanded -> a pill after the label; collapsed -> a numeric badge
  clamped to "99+" pinned to the icon's top-right, while a free-text badge (e.g.
  "new") has no width budget there and degrades to a small dot. Either way the
  value is in aria-label.
- Groups: rendered in document order — items first, then align !== "end" groups,
  with the align: "end" groups pushed down by mt-auto. A group label renders as
  an uppercase caption when expanded and degrades to a divider when collapsed;
  the first group of the top zone never draws a leading divider. Empty groups are
  dropped so a divider can never appear with nothing under it.
- Toggle: when collapsible, a row-shaped <button aria-expanded aria-controls>
  at the foot flips expansion, swapping PanelLeftClose/PanelLeftOpen and its
  aria-label between "Collapse navigation" / "Expand navigation". Pass
  collapsible={false} when the parent owns the state through its own control.
- Tab order is deliberately plain — NO roving tabindex. A nav is a set of links,
  and keyboard users expect Tab to walk every destination (that is the difference
  from a toolbar, which is a single composite widget and therefore uses arrow
  keys with one Tab stop). Do not add arrow-key handling.

Accessibility
- <nav aria-label> wraps a <ul role="list"> + <li> per group (role="list" is
  explicit because Tailwind's list-style: none makes Safari/VoiceOver drop list
  semantics), and a labelled group also puts its label on the <ul> aria-label so
  the grouping survives the collapsed state where the caption is gone.
- The active row gets aria-current="page" plus two visual cues: bg-accent and a
  2px primary bar at its left edge (aria-hidden).
- Icons, the active bar, the divider and the tooltip are aria-hidden; every
  interactive row has outline-none + focus-visible:ring-2 focus-visible:ring-ring.

Rendering & styling
- Semantic tokens only: bg-card + border-r (rail), bg-accent /
  text-accent-foreground (active), hover:bg-muted + hover:text-foreground,
  text-muted-foreground (idle), bg-primary / text-primary-foreground (active bar,
  badge, dot), bg-foreground / text-background + shadow-md (tooltip), bg-border
  (divider), ring-ring (focus). No hex/rgb/oklch literals.
- The tooltip is deliberately inverted (foreground on background) instead of
  bg-popover: in a monochrome dark palette --popover, --card and the rail itself
  are the same surface, so a popover-coloured bubble would be readable only by
  its 10%-white border.
- Rows are h-10 and full width; icons are normalised by a [&_svg]:size-5 wrapper
  so any icon set — or an <img> avatar — drops in.
- The rail must not be given overflow-hidden by its layout, or the tooltips get
  clipped; it also does not scroll, so keep the item count to what fits.

Customization levers
- Density: row h-10, gap-3/px-2.5 (expanded) and the nav's p-2 / gap-2 are the
  knobs — tighten to h-9 for an IDE rail, loosen to h-12 for touch.
- Widths: width / expandedWidth are plain numbers; 60/224 reads like Gmail,
  48/200 like VS Code. Change duration-200 on the nav for a slower reveal.
- Router integration: derive activeId from usePathname() and swap the plain <a>
  for next/link inside the row renderer — the href / button / plain-row branching
  is untouched by that.
- Responsiveness is deliberately the consumer's call: the component ships no
  breakpoints. Drive `expanded` from a media query (or from a container query in
  the shell) and pass collapsible={false} when the layout, not the user, decides.
- Tooltip side: left-full ml-2 mirrors to right-full mr-2 for a right-hand rail;
  drop RowTooltip entirely if your rail is always expanded.
- Badge semantics: swap bg-primary for bg-destructive /
  text-destructive-foreground when the badge means "needs attention" rather than
  "count".
- Slots: `header` takes any ReactNode (brand mark, workspace switcher) and an
  align:"end" group takes the account row — extend through those instead of
  adding a prop per chrome element.

Concepts

  • Icon rail with a widening state — the rail owns exactly one piece of state, its width; expanded (controlled) or defaultExpanded (uncontrolled) picks between width and expandedWidth, and transition-[width] animates the real width property so no wrapper needs overflow-hidden — which is exactly what would otherwise clip the label bubbles.
  • Keyboard-reachable tooltip — the collapsed label bubble reveals on group-focus-visible as well as group-hover, so tabbing the rail surfaces the same names a mouse gets. It stays aria-hidden because the row's aria-label already carries the name — an affordance for sighted keyboard users, not a second ARIA node.
  • Plain Tab order, on purpose — navigation is a set of destinations, not a composite widget: every row keeps its own Tab stop instead of the roving tabindex a toolbar uses. Users expect Tab to walk a nav, and an arrow-key-only rail strands everyone who never presses the arrows.
  • aria-disabled over disabled — a natively disabled button leaves the Tab order and never fires hover or focus, so its icon becomes an unlabelled mystery; aria-disabled keeps the row focusable, tooltip-able and announced as unavailable.
  • Two zones from one arrayalign: "end" moves a group under an mt-auto block, which is how settings and the account avatar sit at the foot of the rail without a second component or a footer prop.
  • Clipping labels, not unmounting them — labels collapse to w-0 with truncate rather than disappearing, so the icon stays centred, long labels ellipsize instead of wrapping, and the text stays available to assistive tech in both states.

On This Page