Side Rail
Navigation

Side Rail

Icon-only vertical rail with hover tooltips, a measured indicator that slides between items, badge dots, and a bottom-pinned action cluster.

Install

npx shadcn@latest add @paragon/side-rail

Also installs: tooltip

side-rail.tsx

"use client";

import * as React from "react";
import {
  Tooltip,
  TooltipContent,
  TooltipProvider,
  TooltipTrigger,
} from "@/registry/paragon/ui/tooltip";
import { cn } from "@/lib/utils";

type SideRailIndicator = "line" | "pill";

interface SideRailContextValue {
  value: string | undefined;
  setValue: (value: string) => void;
  indicator: SideRailIndicator;
}

const SideRailContext = React.createContext<SideRailContextValue | null>(null);

function useSideRail(component: string) {
  const context = React.useContext(SideRailContext);
  if (!context) {
    throw new Error(`<${component}> must be used within <SideRail>`);
  }
  return context;
}

export interface SideRailProps
  extends Omit<React.ComponentProps<"nav">, "onChange"> {
  value?: string;
  defaultValue?: string;
  onValueChange?: (value: string) => void;
  /** "line" slides a short bar along the left edge; "pill" slides a full backdrop. */
  indicator?: SideRailIndicator;
  /** Disables the sliding indicator motion. */
  static?: boolean;
}

/**
 * Icon-only vertical rail. Labels live in right-side tooltips (shared
 * skip-delay, so scanning the rail feels instant), the active indicator is a
 * single measured element that slides vertically between items — first paint
 * suppressed, ResizeObserver snaps layout shifts — and ArrowUp/ArrowDown
 * walk the rail. Pin settings/avatar clusters with <SideRailFooter>.
 */
export function SideRail({
  value: valueProp,
  defaultValue,
  onValueChange,
  indicator = "line",
  static: isStatic = false,
  className,
  children,
  ...props
}: SideRailProps) {
  const [internalValue, setInternalValue] = React.useState(defaultValue);
  const value = valueProp ?? internalValue;
  const navRef = React.useRef<HTMLElement>(null);
  const indicatorRef = React.useRef<HTMLSpanElement>(null);
  const hasPositioned = React.useRef(false);

  const setValue = React.useCallback(
    (next: string) => {
      setInternalValue(next);
      onValueChange?.(next);
    },
    [onValueChange],
  );

  const position = React.useCallback(
    (animate: boolean) => {
      const nav = navRef.current;
      const el = indicatorRef.current;
      if (!nav || !el) return;
      const active = nav.querySelector<HTMLElement>('[data-active="true"]');
      if (!active) {
        el.style.opacity = "0";
        return;
      }
      if (!animate) el.style.transitionProperty = "none";
      el.style.opacity = "1";
      if (indicator === "line") {
        const height = 20;
        el.style.height = `${height}px`;
        el.style.width = "";
        el.style.transform = `translateY(${
          active.offsetTop + (active.offsetHeight - height) / 2
        }px)`;
      } else {
        el.style.height = `${active.offsetHeight}px`;
        el.style.width = `${active.offsetWidth}px`;
        el.style.transform = `translate(${active.offsetLeft}px, ${active.offsetTop}px)`;
      }
      if (!animate) {
        void el.offsetWidth;
        el.style.transitionProperty = "";
      }
    },
    [indicator],
  );

  React.useLayoutEffect(() => {
    position(hasPositioned.current && !isStatic);
    hasPositioned.current = true;
  }, [position, value, isStatic]);

  React.useEffect(() => {
    const nav = navRef.current;
    if (!nav) return;
    const observer = new ResizeObserver(() => position(false));
    observer.observe(nav);
    return () => observer.disconnect();
  }, [position]);

  // ArrowUp/ArrowDown roam the rail, footer included.
  const onKeyDown = (event: React.KeyboardEvent<HTMLElement>) => {
    if (!["ArrowUp", "ArrowDown", "Home", "End"].includes(event.key)) return;
    const nav = navRef.current;
    if (!nav) return;
    const items = Array.from(
      nav.querySelectorAll<HTMLElement>("[data-rail-focusable]:not(:disabled)"),
    );
    if (items.length === 0) return;
    const current = items.indexOf(document.activeElement as HTMLElement);
    let next = 0;
    if (event.key === "ArrowDown") next = (current + 1) % items.length;
    if (event.key === "ArrowUp")
      next = (current - 1 + items.length) % items.length;
    if (event.key === "End") next = items.length - 1;
    items[next]?.focus();
    event.preventDefault();
  };

  const contextValue = React.useMemo(
    () => ({ value, setValue, indicator }),
    [value, setValue, indicator],
  );

  return (
    <SideRailContext.Provider value={contextValue}>
      <TooltipProvider>
        <nav
          ref={navRef}
          data-slot="side-rail"
          onKeyDown={onKeyDown}
          className={cn(
            "relative flex h-full w-13 flex-col items-center gap-1 bg-background px-2 py-2.5",
            className,
          )}
          {...props}
        >
          <span
            ref={indicatorRef}
            aria-hidden
            className={cn(
              "pointer-events-none absolute top-0 opacity-0 motion-reduce:transition-none",
              indicator === "line"
                ? "left-0 w-0.5 rounded-r-full bg-foreground [transition-property:transform,height] duration-200 ease-out"
                : "left-0 rounded-lg bg-accent [transition-property:transform,width,height] duration-200 ease-out",
            )}
          />
          {children}
        </nav>
      </TooltipProvider>
    </SideRailContext.Provider>
  );
}

interface SideRailButtonBaseProps extends React.ComponentProps<"button"> {
  /** Tooltip + accessible label. */
  label: string;
  /** Notification marker: `true` for a dot, a number for a count. */
  badge?: boolean | number;
}

function RailBadge({ badge }: { badge: boolean | number }) {
  if (badge === false) return null;
  if (badge === true) {
    return (
      <span
        aria-hidden
        className="absolute top-1 right-1 size-2 rounded-full bg-destructive ring-2 ring-background"
      />
    );
  }
  return (
    <span
      aria-hidden
      className="absolute -top-1 -right-1 flex h-4 min-w-4 items-center justify-center rounded-full bg-primary px-1 text-[10px] leading-none font-medium text-primary-foreground tabular-nums ring-2 ring-background"
    >
      {badge > 99 ? "99+" : badge}
    </span>
  );
}

function railItemClasses(active: boolean) {
  return cn(
    "relative z-10 flex size-9 shrink-0 items-center justify-center rounded-lg outline-none select-none after:absolute after:-inset-0.5",
    "transition-[background-color,color,scale] duration-150 ease-out active:not-disabled:scale-[0.97]",
    "focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
    "[&_svg]:pointer-events-none [&_svg]:size-[18px] [&_svg]:shrink-0",
    active
      ? "text-foreground"
      : "text-muted-foreground hover:bg-accent/70 hover:text-foreground",
  );
}

export interface SideRailItemProps extends SideRailButtonBaseProps {
  /** Unique value for selection. */
  value: string;
}

export function SideRailItem({
  value,
  label,
  badge = false,
  className,
  children,
  onClick,
  ...props
}: SideRailItemProps) {
  const rail = useSideRail("SideRailItem");
  const active = rail.value === value;
  return (
    <Tooltip>
      <TooltipTrigger asChild>
        <button
          type="button"
          data-slot="side-rail-item"
          data-rail-focusable=""
          data-active={active || undefined}
          aria-label={label}
          aria-current={active ? "page" : undefined}
          onClick={(event) => {
            rail.setValue(value);
            onClick?.(event);
          }}
          className={cn(railItemClasses(active), className)}
          {...props}
        >
          {children}
          <RailBadge badge={badge} />
        </button>
      </TooltipTrigger>
      <TooltipContent side="right" sideOffset={10}>
        {label}
        {typeof badge === "number" && badge > 0 && (
          <span className="text-primary-foreground/60 tabular-nums">
            {" "}
            · {badge}
          </span>
        )}
      </TooltipContent>
    </Tooltip>
  );
}

/** Non-selectable rail action (settings, help, avatar). */
export function SideRailButton({
  label,
  badge = false,
  className,
  children,
  ...props
}: SideRailButtonBaseProps) {
  return (
    <Tooltip>
      <TooltipTrigger asChild>
        <button
          type="button"
          data-slot="side-rail-button"
          data-rail-focusable=""
          aria-label={label}
          className={cn(railItemClasses(false), className)}
          {...props}
        >
          {children}
          <RailBadge badge={badge} />
        </button>
      </TooltipTrigger>
      <TooltipContent side="right" sideOffset={10}>
        {label}
      </TooltipContent>
    </Tooltip>
  );
}

export function SideRailSeparator({
  className,
  ...props
}: React.ComponentProps<"div">) {
  return (
    <div
      aria-hidden
      data-slot="side-rail-separator"
      className={cn("my-1 h-px w-6 shrink-0 bg-border", className)}
      {...props}
    />
  );
}

/** Bottom-pinned cluster for settings/avatar actions. */
export function SideRailFooter({
  className,
  ...props
}: React.ComponentProps<"div">) {
  return (
    <div
      data-slot="side-rail-footer"
      className={cn("mt-auto flex flex-col items-center gap-1 pt-2", className)}
      {...props}
    />
  );
}