# Icon Rail

> A narrow icon-only sidebar rail with tooltips, a sliding active indicator and an expandable labels mode.

- Author: Atelier UI
- License: MIT
- Page: https://chromotion.dev/en/components/icon-rail
- Site: Atelier UI (https://chromotion.dev)

## Install

```bash
npx shadcn@latest add https://chromotion.dev/r/icon-rail.json
```

Stack: React + TypeScript + Tailwind CSS v4, shadcn/ui conventions. `cn` comes from `@/lib/utils`.

## Dependencies

```bash
npm install lucide-react
```

## Usage

```tsx title="components/icon-rail-demo.tsx"
"use client"

import * as React from "react"
import { BarChart3, FolderKanban, Home, Inbox, Layers, Settings, Users } from "lucide-react"

import { IconRail, type IconRailItem } from "@/components/icon-rail"

const items: IconRailItem[] = [
  { id: "home", label: "Home", icon: <Home /> },
  { id: "projects", label: "Projects", icon: <FolderKanban /> },
  { id: "inbox", label: "Inbox", icon: <Inbox />, badge: 3 },
  { id: "analytics", label: "Analytics", icon: <BarChart3 /> },
  { id: "team", label: "Team", icon: <Users /> },
]

const bottomItems: IconRailItem[] = [
  { id: "settings", label: "Settings", icon: <Settings /> },
  {
    id: "profile",
    label: "Ada Lovelace",
    icon: (
      <span className="flex size-5 items-center justify-center rounded-full bg-primary text-[9px] font-semibold text-primary-foreground">
        AL
      </span>
    ),
  },
]

const copy: Record<string, string> = {
  home: "A snapshot of everything moving across your workspace today.",
  projects: "Eleven active projects, two waiting on review.",
  inbox: "Three unread mentions and one approval request.",
  analytics: "Weekly active users are up 8.2% on the previous period.",
  team: "Eight teammates, one invite pending.",
  settings: "Workspace name, billing and integrations.",
  profile: "Your personal details and notification preferences.",
}

export default function IconRailDemo() {
  const [active, setActive] = React.useState("projects")
  const all = [...items, ...bottomItems]
  const current = all.find((item) => item.id === active)

  return (
    <div className="flex h-[26rem] w-full max-w-2xl overflow-hidden rounded-2xl border bg-muted/30">
      <IconRail
        aria-label="Workspace rail"
        header={<Layers className="text-primary" aria-hidden />}
        items={items}
        bottomItems={bottomItems}
        activeId={active}
        onActiveChange={setActive}
      />
      <main className="flex min-w-0 flex-1 flex-col gap-3 p-6">
        <h2 className="truncate text-lg font-semibold">{current?.label}</h2>
        <p className="text-sm text-muted-foreground">{copy[active]}</p>
        <div className="mt-2 grid grid-cols-2 gap-3">
          <div className="h-20 rounded-xl border bg-card" />
          <div className="h-20 rounded-xl border bg-card" />
          <div className="col-span-2 h-28 rounded-xl border bg-card" />
        </div>
      </main>
    </div>
  )
}
```

## Source

```tsx title="components/icon-rail.tsx"
"use client"

import * as React from "react"
import { PanelLeftClose, PanelLeftOpen } from "lucide-react"

import { cn } from "@/lib/utils"

export interface IconRailItem {
  id: string
  label: string
  icon: React.ReactNode
  badge?: string | number
}

export interface IconRailProps extends Omit<React.ComponentProps<"aside">, "children" | "onChange"> {
  /** Primary destinations, listed from the top */
  items: IconRailItem[]
  /** Pinned to the bottom, e.g. settings and the account avatar */
  bottomItems?: IconRailItem[]
  /** Brand mark shown at the top */
  header?: React.ReactNode
  activeId?: string
  defaultActiveId?: string
  onActiveChange?: (id: string) => void
  /** Whether labels are shown (controlled) */
  expanded?: boolean
  defaultExpanded?: boolean
  onExpandedChange?: (expanded: boolean) => void
  /** Width in px when expanded */
  expandedWidth?: number
}

interface IndicatorRect {
  top: number
  height: number
}

/** Visual label shown beside a collapsed item while it is hovered or keyboard-focused */
function RailTooltip({ children }: { children: React.ReactNode }) {
  return (
    <span
      role="tooltip"
      className="pointer-events-none invisible absolute top-1/2 left-full z-50 ml-3 -translate-y-1/2 rounded-md bg-foreground px-2 py-1 text-xs font-medium whitespace-nowrap text-background opacity-0 shadow-md transition-[opacity,visibility] duration-150 group-hover/item:visible group-hover/item:opacity-100 group-has-[:focus-visible]/item:visible group-has-[:focus-visible]/item:opacity-100 motion-reduce:transition-none"
    >
      {children}
    </span>
  )
}

const itemClass =
  "relative z-10 flex h-11 w-full items-center gap-3 overflow-hidden rounded-xl px-3.5 text-sm font-medium whitespace-nowrap transition-colors outline-none focus-visible:ring-4 focus-visible:ring-ring/40 [&_svg]:size-5 [&_svg]:shrink-0"

export function IconRail({
  items,
  bottomItems = [],
  header,
  activeId: activeProp,
  defaultActiveId,
  onActiveChange,
  expanded: expandedProp,
  defaultExpanded = false,
  onExpandedChange,
  expandedWidth = 224,
  className,
  style,
  ...props
}: IconRailProps) {
  const [innerActive, setInnerActive] = React.useState(defaultActiveId ?? items[0]?.id)
  const [innerExpanded, setInnerExpanded] = React.useState(defaultExpanded)
  const [rect, setRect] = React.useState<IndicatorRect | null>(null)
  const active = activeProp ?? innerActive
  const expanded = expandedProp ?? innerExpanded
  const ToggleIcon = expanded ? PanelLeftClose : PanelLeftOpen

  // Measures the active item relative to the nav; only called from ref callbacks, never during render.
  const measure = React.useCallback((el: HTMLElement | null) => {
    const nav = el?.closest("nav")
    if (!el || !nav) return
    const top = el.getBoundingClientRect().top - nav.getBoundingClientRect().top + nav.scrollTop
    const next = { top: Math.round(top), height: el.offsetHeight }
    setRect((prev) => (prev && prev.top === next.top && prev.height === next.height ? prev : next))
  }, [])

  const navRef = React.useCallback(
    (node: HTMLElement | null) => {
      if (!node) return
      const observer = new ResizeObserver(() => {
        measure(node.querySelector<HTMLElement>('[aria-current="page"]'))
      })
      observer.observe(node)
      return () => observer.disconnect()
    },
    [measure]
  )

  const setActive = (id: string) => {
    if (activeProp === undefined) setInnerActive(id)
    onActiveChange?.(id)
  }

  const setExpanded = (next: boolean) => {
    if (expandedProp === undefined) setInnerExpanded(next)
    onExpandedChange?.(next)
  }

  const renderItem = (item: IconRailItem) => {
    const isActive = item.id === active
    return (
      <li key={item.id} className="group/item relative">
        <button
          ref={isActive ? measure : undefined}
          type="button"
          aria-current={isActive ? "page" : undefined}
          onClick={() => setActive(item.id)}
          className={cn(itemClass, isActive ? "text-primary" : "text-muted-foreground hover:bg-accent/70 hover:text-foreground")}
        >
          <span className="relative flex shrink-0 items-center justify-center">
            {item.icon}
            {item.badge !== undefined && !expanded && (
              <span
                aria-hidden
                className="absolute -top-1 -right-1 size-2 rounded-full bg-primary ring-2 ring-background"
              />
            )}
          </span>
          <span className={cn("min-w-0 flex-1 truncate text-left transition-opacity duration-200", expanded ? "opacity-100 delay-100" : "opacity-0")}>
            {item.label}
          </span>
          {item.badge !== undefined && expanded && (
            <span className="rounded-full bg-primary/10 px-1.5 text-[11px] leading-4.5 font-medium text-primary tabular-nums">
              {item.badge}
            </span>
          )}
        </button>
        {!expanded && <RailTooltip>{item.label}</RailTooltip>}
      </li>
    )
  }

  return (
    <aside
      style={{ width: expanded ? expandedWidth : 64, ...style }}
      className={cn(
        "flex h-full shrink-0 flex-col border-r bg-background py-3 transition-[width] duration-300 ease-[cubic-bezier(.32,.72,0,1)] motion-reduce:transition-none",
        className
      )}
      {...props}
    >
      {header && (
        <div className="mb-3 flex h-11 shrink-0 items-center gap-3 overflow-hidden px-5 whitespace-nowrap [&_svg]:size-6 [&_svg]:shrink-0">
          {header}
        </div>
      )}
      <nav ref={navRef} aria-label="Primary" className="relative flex min-h-0 flex-1 flex-col gap-4 px-2">
        <span
          aria-hidden
          className={cn(
            "pointer-events-none absolute inset-x-2 top-0 rounded-xl bg-primary/10 transition-[transform,opacity] duration-300 ease-[cubic-bezier(.32,.72,0,1)] before:absolute before:top-2.5 before:bottom-2.5 before:-left-2 before:w-1 before:rounded-r-full before:bg-primary motion-reduce:transition-none",
            rect ? "opacity-100" : "opacity-0"
          )}
          style={rect ? { height: rect.height, transform: `translateY(${rect.top}px)` } : undefined}
        />
        <ul className="flex flex-col gap-1">{items.map(renderItem)}</ul>
        <ul className="mt-auto flex flex-col gap-1">
          {bottomItems.map(renderItem)}
          <li className="group/item relative">
            <button
              type="button"
              aria-expanded={expanded}
              aria-label={expanded ? "Collapse sidebar" : "Expand sidebar"}
              onClick={() => setExpanded(!expanded)}
              className={cn(itemClass, "text-muted-foreground hover:bg-accent/70 hover:text-foreground")}
            >
              <ToggleIcon />
              <span className={cn("min-w-0 flex-1 truncate text-left transition-opacity duration-200", expanded ? "opacity-100 delay-100" : "opacity-0")}>
                Collapse
              </span>
            </button>
            {!expanded && <RailTooltip>Expand</RailTooltip>}
          </li>
        </ul>
      </nav>
    </aside>
  )
}
```
