{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "icon-rail",
  "type": "registry:ui",
  "title": "Icon Rail",
  "description": "Sidebar that folds to icons in place, with tooltips only when narrow.",
  "categories": [
    "app-nav"
  ],
  "dependencies": [
    "@base-ui/react",
    "@number-flow/react",
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/motion.json",
    "https://www.stealth.pm/r/use-controllable-state.json"
  ],
  "files": [
    {
      "path": "components/ui/icon-rail.tsx",
      "content": "\"use client\";\nimport { Tooltip } from \"@base-ui/react/tooltip\";\nimport NumberFlow from \"@number-flow/react\";\nimport { animate, motion, useMotionValue, useReducedMotion } from \"motion/react\";\nimport { createContext, useCallback, useContext, useEffect, useId, useMemo, useRef, useState, useSyncExternalStore } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { spring } from \"@/lib/motion\";\nimport { useControllableState } from \"@/lib/use-controllable-state\";\n\n/* -------------------------------------------------------------------------------------------------\n * Root\n * -----------------------------------------------------------------------------------------------*/\n\ntype RailContextValue = {\n  collapsed: boolean;\n  toggle: (via: \"pointer\" | \"key\") => void;\n  value: string | null;\n  select: (value: string) => void;\n  pill: string;\n  navId: string;\n  shortcut: string | false;\n};\n\nconst RailContext = createContext<RailContextValue | null>(null);\n\nfunction useRail(part: string) {\n  const ctx = useContext(RailContext);\n  if (!ctx) throw new Error(`<${part}> must be inside <IconRail>`);\n  return ctx;\n}\n\n// \"mod\" is ⌘ on Apple devices and Ctrl elsewhere. The server assumes ⌘; the glyph\n// only appears inside a tooltip, which never renders before hydration.\nconst noop = () => () => {};\nconst readApple = () => /Mac|iPhone|iPad|iPod/.test(navigator.platform || navigator.userAgent);\nfunction useApple() {\n  return useSyncExternalStore(noop, readApple, () => true);\n}\n\nfunction matches(e: KeyboardEvent, shortcut: string) {\n  const parts = shortcut.toLowerCase().split(\"+\");\n  const key = parts.pop();\n  const mod = parts.includes(\"mod\");\n  if (mod !== (e.metaKey || e.ctrlKey)) return false;\n  if (parts.includes(\"shift\") !== e.shiftKey || parts.includes(\"alt\") !== e.altKey) return false;\n  return e.key.toLowerCase() === key;\n}\n\nexport type IconRailProps = Omit<React.ComponentProps<\"aside\">, \"defaultValue\" | \"onChange\"> & {\n  collapsed?: boolean;\n  defaultCollapsed?: boolean;\n  onCollapsedChange?: (collapsed: boolean) => void;\n  /** The active item's value. */\n  value?: string | null;\n  defaultValue?: string | null;\n  onValueChange?: (value: string | null) => void;\n  /** Keyboard toggle, as \"mod+b\" or \"mod+shift+s\". `false` turns it off. Ignored while typing in a field. */\n  shortcut?: string | false;\n  /** Widths in px. */\n  expandedWidth?: number;\n  collapsedWidth?: number;\n};\n\nexport function IconRail({\n  collapsed: collapsedProp,\n  defaultCollapsed = false,\n  onCollapsedChange,\n  value,\n  defaultValue = null,\n  onValueChange,\n  shortcut = \"mod+b\",\n  expandedWidth = 220,\n  collapsedWidth = 48,\n  className,\n  style,\n  children,\n  ...rest\n}: IconRailProps) {\n  const [collapsed, setCollapsed] = useControllableState({ value: collapsedProp, defaultValue: defaultCollapsed, onChange: onCollapsedChange });\n  const [current, setCurrent] = useControllableState<string | null>({ value, defaultValue, onChange: onValueChange });\n  // Keyboard toggles land on the same frame; a pointer toggle animates. Motion on a\n  // shortcut someone presses all day reads as latency.\n  const [instant, setInstant] = useState(false);\n  const pill = useId();\n  const navId = useId();\n\n  const toggle = useCallback(\n    (via: \"pointer\" | \"key\") => {\n      setInstant(via === \"key\");\n      setCollapsed((c) => !c);\n    },\n    [setCollapsed],\n  );\n\n  useEffect(() => {\n    if (!shortcut) return;\n    const onKey = (e: KeyboardEvent) => {\n      if (e.defaultPrevented || e.repeat || !matches(e, shortcut)) return;\n      const t = e.target as HTMLElement | null;\n      if (t?.closest(\"input, textarea, select, [contenteditable=''], [contenteditable='true']\")) return;\n      e.preventDefault();\n      toggle(\"key\");\n    };\n    window.addEventListener(\"keydown\", onKey);\n    return () => window.removeEventListener(\"keydown\", onKey);\n  }, [shortcut, toggle]);\n\n  const ctx = useMemo(\n    () => ({ collapsed, toggle, value: current, select: (v: string) => setCurrent(v), pill, navId, shortcut }),\n    [collapsed, toggle, current, setCurrent, pill, navId, shortcut],\n  );\n\n  return (\n    <RailContext.Provider value={ctx}>\n      <Tooltip.Provider delay={400} timeout={400}>\n        <aside\n          data-collapsed={collapsed || undefined}\n          data-instant={instant || undefined}\n          style={{ width: collapsed ? collapsedWidth : expandedWidth, ...style }}\n          className={cn(\n            \"group/rail flex h-full min-h-0 shrink-0 flex-col overflow-hidden border-r border-line bg-frame text-fg\",\n            \"transition-[width] duration-260 ease-in-out-quart data-instant:duration-0\",\n            className,\n          )}\n          {...rest}\n        >\n          {children}\n        </aside>\n      </Tooltip.Provider>\n    </RailContext.Provider>\n  );\n}\n\n/* -------------------------------------------------------------------------------------------------\n * Shared pieces\n * -----------------------------------------------------------------------------------------------*/\n\n// Everything that isn't an icon fades and slides 4px toward the icons as the rail\n// narrows, quickly and with no delay; on the way back it waits 70ms so the width\n// has room for it before it arrives.\nconst fadeLabel = cn(\n  \"transition-[opacity,translate] duration-200 delay-70 ease-out-expo\",\n  \"group-data-collapsed/rail:-translate-x-1 group-data-collapsed/rail:opacity-0 group-data-collapsed/rail:delay-0 group-data-collapsed/rail:duration-100\",\n  \"group-data-instant/rail:transition-none motion-reduce:translate-x-0\",\n);\n\nconst focusRing = \"outline-none focus-visible:outline-solid focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-fg-3\";\n\nconst tipClass = cn(\n  \"flex items-center gap-2 rounded-lg border border-line-2 bg-raised px-2 py-1 text-[12px] leading-4 text-fg shadow-pop outline-none\",\n  \"origin-(--transform-origin) transition-[opacity,scale,translate] duration-150 ease-out-expo\",\n  \"data-starting-style:-translate-x-0.5 data-starting-style:scale-96 data-starting-style:opacity-0 data-ending-style:opacity-0 data-ending-style:duration-100\",\n  \"data-instant:transition-none motion-reduce:translate-x-0 motion-reduce:scale-100\",\n);\n\nfunction RailTooltip({ label, detail, disabled, children }: { label: React.ReactNode; detail?: React.ReactNode; disabled: boolean; children: React.ReactElement }) {\n  return (\n    <Tooltip.Root disabled={disabled}>\n      <Tooltip.Trigger render={children} />\n      <Tooltip.Portal>\n        <Tooltip.Positioner side=\"right\" sideOffset={10} className=\"z-(--z-tooltip)\">\n          <Tooltip.Popup className={tipClass}>\n            {label}\n            {detail}\n          </Tooltip.Popup>\n        </Tooltip.Positioner>\n      </Tooltip.Portal>\n    </Tooltip.Root>\n  );\n}\n\n/* -------------------------------------------------------------------------------------------------\n * Header, content, group, footer\n * -----------------------------------------------------------------------------------------------*/\n\nexport type IconRailHeaderProps = Omit<React.ComponentProps<\"div\">, \"title\"> & {\n  /** A 32px mark that stays put in both states: a logo or a workspace tile. */\n  mark: React.ReactNode;\n  /** Shown beside the mark when expanded. */\n  title?: React.ReactNode;\n};\n\nexport function IconRailHeader({ mark, title, className, children, ...rest }: IconRailHeaderProps) {\n  return (\n    <div className={cn(\"flex h-12 shrink-0 items-center gap-2 px-2\", className)} {...rest}>\n      <span className=\"grid size-8 shrink-0 place-items-center\">{mark}</span>\n      {title && <span className={cn(\"min-w-0 flex-1 truncate whitespace-nowrap text-[13px] font-medium tracking-[-0.01em]\", fadeLabel)}>{title}</span>}\n      {children}\n    </div>\n  );\n}\n\ntype WashContextValue = { show: (el: HTMLElement) => void; hide: () => void };\nconst WashContext = createContext<WashContextValue | null>(null);\n\nexport type IconRailContentProps = React.ComponentProps<\"nav\">;\n\n/** The labeled nav landmark holding the items. ↑ ↓ Home End move between them. */\nexport function IconRailContent({ className, children, onKeyDown, \"aria-label\": label = \"Main\", ...rest }: IconRailContentProps) {\n  const { navId } = useRail(\"IconRailContent\");\n  const reduce = !!useReducedMotion();\n  const listRef = useRef<HTMLDivElement>(null);\n  const y = useMotionValue(0);\n  const [on, setOn] = useState(false);\n  const shown = useRef(false);\n  const hiddenAt = useRef(0);\n\n  // One wash for the list, as in the sidebar: it glides after the pointer and\n  // appears in place when the pointer first arrives.\n  const show = useCallback(\n    (el: HTMLElement) => {\n      const list = listRef.current;\n      if (!list) return;\n      const top = el.getBoundingClientRect().top - list.getBoundingClientRect().top;\n      if (!reduce && (shown.current || performance.now() - hiddenAt.current < 150)) animate(y, top, spring.follow);\n      else y.jump(top);\n      shown.current = true;\n      setOn(true);\n    },\n    [reduce, y],\n  );\n  const hide = useCallback(() => {\n    if (!shown.current) return;\n    shown.current = false;\n    hiddenAt.current = performance.now();\n    setOn(false);\n  }, []);\n  const wash = useMemo(() => ({ show, hide }), [show, hide]);\n\n  return (\n    <WashContext.Provider value={wash}>\n      <nav\n        id={navId}\n        aria-label={label}\n        className={cn(\"min-h-0 flex-1 overflow-y-auto overflow-x-hidden overscroll-contain px-2 py-1 [scrollbar-width:none]\", className)}\n        onKeyDown={(e) => {\n          onKeyDown?.(e);\n          if (e.defaultPrevented || ![\"ArrowDown\", \"ArrowUp\", \"Home\", \"End\"].includes(e.key)) return;\n          const rows = [...(listRef.current?.querySelectorAll<HTMLElement>(\"[data-rail-row]\") ?? [])].filter((el) => !el.matches(\":disabled,[aria-disabled=true]\"));\n          const at = rows.indexOf(e.target as HTMLElement);\n          if (at < 0) return;\n          e.preventDefault();\n          const next = e.key === \"Home\" ? 0 : e.key === \"End\" ? rows.length - 1 : Math.min(rows.length - 1, Math.max(0, at + (e.key === \"ArrowDown\" ? 1 : -1)));\n          rows[next]?.focus();\n        }}\n        {...rest}\n      >\n        <div ref={listRef} className=\"relative flex flex-col\" onPointerLeave={hide}>\n          <motion.span\n            aria-hidden\n            className=\"pointer-events-none absolute inset-x-0 top-0 h-8 rounded-md bg-hover\"\n            style={{ y }}\n            initial={false}\n            animate={{ opacity: on ? 1 : 0 }}\n            transition={{ duration: on ? 0.12 : 0.15, ease: \"easeOut\" }}\n          />\n          {children}\n        </div>\n      </nav>\n    </WashContext.Provider>\n  );\n}\n\nexport type IconRailGroupProps = React.ComponentProps<\"div\"> & { label?: string };\n\n/**\n * A labeled run of items. When the rail narrows, the label gives way to a short\n * hairline in the same 28px row, so no icon moves vertically.\n */\nexport function IconRailGroup({ label, className, children, ...rest }: IconRailGroupProps) {\n  const id = useId();\n  return (\n    <div role=\"group\" aria-labelledby={label ? id : undefined} className={cn(\"flex flex-col gap-px pt-2 first:pt-0\", className)} {...rest}>\n      {label && (\n        <div className=\"relative flex h-7 items-center px-2\">\n          <span id={id} className={cn(\"truncate whitespace-nowrap font-mono text-[10.5px] uppercase leading-none tracking-[0.08em] text-fg-3\", fadeLabel)}>\n            {label}\n          </span>\n          <span\n            aria-hidden\n            className=\"absolute left-2.5 top-1/2 h-px w-3 bg-line-2 opacity-0 transition-opacity duration-150 group-data-collapsed/rail:opacity-100 group-data-collapsed/rail:delay-100 group-data-instant/rail:transition-none\"\n          />\n        </div>\n      )}\n      {children}\n    </div>\n  );\n}\n\nexport type IconRailFooterProps = React.ComponentProps<\"div\">;\n\nexport function IconRailFooter({ className, ...rest }: IconRailFooterProps) {\n  return <div className={cn(\"flex shrink-0 flex-col gap-px border-t border-line p-2\", className)} {...rest} />;\n}\n\n/* -------------------------------------------------------------------------------------------------\n * Item\n * -----------------------------------------------------------------------------------------------*/\n\nexport type IconRailItemProps = Omit<React.ComponentProps<\"a\">, \"children\"> & {\n  value: string;\n  /** The 16px icon: the only thing left when the rail is narrow. */\n  icon: React.ReactNode;\n  /** The label. Also the tooltip and the accessible name when narrow. */\n  children: string;\n  /** Rolls when it changes; becomes a dot on the icon when narrow. */\n  count?: number;\n  disabled?: boolean;\n  /** Your router's link element, e.g. <Link href=\"/inbox\" />. */\n  render?: React.ReactElement;\n};\n\nexport function IconRailItem({ value, icon, children, count, disabled = false, href, render, className, onClick, onPointerEnter, ...rest }: IconRailItemProps) {\n  const { collapsed, value: current, select, pill } = useRail(\"IconRailItem\");\n  const wash = useContext(WashContext);\n  const reduce = useReducedMotion();\n  const active = current === value;\n  const hasCount = typeof count === \"number\" && count > 0;\n  const element = render ?? (href && !disabled ? <a href={href} /> : <button type=\"button\" disabled={disabled} />);\n\n  return (\n    <RailTooltip label={children} detail={hasCount ? <span className=\"text-[11.5px] tabular text-fg-3\">{count}</span> : undefined} disabled={!collapsed || disabled}>\n      {cloneWith(element, {\n        ...rest,\n        \"aria-current\": active ? \"page\" : undefined,\n        \"aria-disabled\": disabled || undefined,\n        \"data-rail-row\": \"\",\n        \"data-active\": active || undefined,\n        \"data-disabled\": disabled || undefined,\n        onClick: (e: React.MouseEvent<HTMLAnchorElement>) => {\n          if (disabled) return e.preventDefault();\n          onClick?.(e);\n          select(value);\n        },\n        onPointerEnter: (e: React.PointerEvent<HTMLAnchorElement>) => {\n          onPointerEnter?.(e);\n          if (e.pointerType === \"touch\") return;\n          if (disabled) wash?.hide();\n          else wash?.show(e.currentTarget);\n        },\n        className: cn(\n          \"group/item relative flex h-8 w-full min-w-0 select-none items-center rounded-md pr-2 text-left text-[13px] text-fg-2 no-underline\",\n          \"transition-[color,scale,background-color] duration-150 active:scale-[0.97] active:duration-75 hover:text-fg data-active:text-fg\",\n          \"pointer-coarse:active:bg-hover data-disabled:pointer-events-none data-disabled:opacity-50\",\n          // Narrow rows are 32px squares; on touch the hit area grows to 44px without changing the drawing.\n          \"before:absolute before:-inset-x-2 before:-inset-y-1.5 before:content-[''] pointer-fine:before:hidden\",\n          !wash && \"hover:bg-hover\",\n          focusRing,\n          className,\n        ),\n        children: (\n          <>\n            {active && (\n              <motion.span\n                layoutId={pill}\n                aria-hidden\n                className=\"absolute inset-0 rounded-md border border-line-2 bg-raised shadow-[var(--shadow)]\"\n                transition={reduce ? { duration: 0 } : spring.snappy}\n              />\n            )}\n            <span aria-hidden className=\"relative grid size-8 shrink-0 place-items-center text-fg-3 transition-colors duration-150 group-hover/item:text-fg-2 group-data-active/item:text-fg [&_svg]:size-4\">\n              {icon}\n              {/* Narrow, the count becomes a dot on the icon; the number moves to the tooltip. */}\n              <span\n                className={cn(\n                  \"absolute right-1.5 top-1.5 size-1.5 scale-0 rounded-full bg-fg opacity-0 ring-2 ring-frame transition-[scale,opacity] duration-200 ease-out-expo\",\n                  hasCount && \"group-data-collapsed/rail:scale-100 group-data-collapsed/rail:opacity-100 group-data-collapsed/rail:delay-100\",\n                  \"group-data-instant/rail:transition-none group-data-active/item:ring-raised\",\n                )}\n              />\n            </span>\n            <span className={cn(\"relative min-w-0 flex-1 truncate whitespace-nowrap pl-0.5\", fadeLabel)}>{children}</span>\n            {hasCount && (\n              <span className={cn(\"relative shrink-0 text-[11.5px] tabular text-fg-3 group-data-active/item:text-fg-2\", fadeLabel)}>\n                <NumberFlow value={count} />\n              </span>\n            )}\n          </>\n        ),\n      })}\n    </RailTooltip>\n  );\n}\n\n// Adds our props and classes to the caller's element (their Link, or our default\n// anchor or button). Their own className is kept and ours goes first.\nfunction cloneWith(element: React.ReactElement, props: Record<string, unknown>) {\n  const own = element.props as Record<string, unknown>;\n  const Type = element.type as React.ElementType;\n  return <Type {...own} {...props} className={cn(props.className as string, own.className as string)} />;\n}\n\n/* -------------------------------------------------------------------------------------------------\n * Toggle\n * -----------------------------------------------------------------------------------------------*/\n\nexport type IconRailToggleProps = Omit<React.ComponentProps<\"button\">, \"children\"> & {\n  /** Label while expanded. */\n  collapseLabel?: string;\n  /** Label while narrow; also its tooltip. */\n  expandLabel?: string;\n};\n\n/** The row that folds the rail. It shows the shortcut in its tooltip when narrow. */\nexport function IconRailToggle({ collapseLabel = \"Collapse\", expandLabel = \"Expand sidebar\", className, onClick, ...rest }: IconRailToggleProps) {\n  const { collapsed, toggle, navId, shortcut } = useRail(\"IconRailToggle\");\n  const apple = useApple();\n  const keys = shortcut ? shortcut.split(\"+\").map((k) => (k === \"mod\" ? (apple ? \"⌘\" : \"Ctrl\") : k === \"shift\" ? \"⇧\" : k === \"alt\" ? (apple ? \"⌥\" : \"Alt\") : k.toUpperCase())) : [];\n  const label = collapsed ? expandLabel : collapseLabel;\n\n  return (\n    <RailTooltip\n      label={label}\n      disabled={!collapsed}\n      detail={\n        keys.length ? (\n          <kbd className=\"flex gap-0.5\">\n            {keys.map((k) => (\n              <span key={k} className=\"grid h-[18px] min-w-[18px] place-items-center rounded-[5px] border border-line-2 bg-frame px-1 font-mono text-[10.5px] leading-none text-fg-2\">\n                {k}\n              </span>\n            ))}\n          </kbd>\n        ) : undefined\n      }\n    >\n      <button\n        type=\"button\"\n        aria-label={label}\n        aria-expanded={!collapsed}\n        aria-controls={navId}\n        aria-keyshortcuts={shortcut ? shortcut.replace(/mod/i, apple ? \"Meta\" : \"Control\").replace(/\\b\\w/g, (c) => c.toUpperCase()) : undefined}\n        onClick={(e) => {\n          onClick?.(e);\n          if (!e.defaultPrevented) toggle(\"pointer\");\n        }}\n        className={cn(\n          \"group/item relative flex h-8 w-full min-w-0 select-none items-center rounded-md pr-2 text-left text-[13px] text-fg-3\",\n          \"transition-[color,scale,background-color] duration-150 hover:bg-hover hover:text-fg active:scale-[0.97] active:duration-75\",\n          \"before:absolute before:-inset-x-2 before:-inset-y-1.5 before:content-[''] pointer-fine:before:hidden\",\n          focusRing,\n          className,\n        )}\n        {...rest}\n      >\n        <span aria-hidden className=\"grid size-8 shrink-0 place-items-center\">\n          <svg\n            width=\"16\"\n            height=\"16\"\n            viewBox=\"0 0 16 16\"\n            fill=\"none\"\n            stroke=\"currentColor\"\n            strokeWidth=\"1.4\"\n            strokeLinecap=\"round\"\n            strokeLinejoin=\"round\"\n            className=\"transition-[rotate] duration-260 ease-in-out-quart group-data-collapsed/rail:rotate-180 group-data-instant/rail:transition-none\"\n          >\n            <path d=\"M8.25 4.5 4.75 8l3.5 3.5M11.75 4.5 8.25 8l3.5 3.5\" />\n          </svg>\n        </span>\n        <span aria-hidden className={cn(\"min-w-0 flex-1 truncate whitespace-nowrap pl-0.5\", fadeLabel)}>\n          {collapseLabel}\n        </span>\n        {keys.length > 0 && (\n          <kbd aria-hidden className={cn(\"flex shrink-0 gap-0.5 font-mono text-[10.5px] text-fg-4 pointer-coarse:hidden\", fadeLabel)}>\n            {keys.map((k) => (\n              <span key={k}>{k}</span>\n            ))}\n          </kbd>\n        )}\n      </button>\n    </RailTooltip>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/icon-rail"
}
