Navigation

Sidebar Nav

App sidebar with collapsible groups, a sliding active indicator, badge counts, and a collapsed icon-rail mode.

Install

npx shadcn@latest add @paragon/sidebar-nav

sidebar-nav.tsx

"use client";

import * as React from "react";
import { ChevronDown } from "lucide-react";
import { cn } from "@/lib/utils";

interface SidebarNavContextValue {
  collapsed: boolean;
}

const SidebarNavContext = React.createContext<SidebarNavContextValue | null>(
  null,
);

function useSidebarNav() {
  const context = React.useContext(SidebarNavContext);
  if (!context) {
    throw new Error("SidebarNav parts must be used within <SidebarNav>");
  }
  return context;
}

/**
 * Width-collapsing label. The grid column animates 1fr↔0fr (the accordion
 * trick rotated 90°) so the sidebar narrows to icons without animating width.
 */
function CollapsingLabel({
  children,
  className,
}: {
  children: React.ReactNode;
  className?: string;
}) {
  return (
    <span
      className={cn(
        "grid [grid-template-columns:1fr] transition-[grid-template-columns] duration-[250ms] ease-out group-data-[collapsed]/nav:[grid-template-columns:0fr] motion-reduce:transition-none",
        className,
      )}
    >
      <span className="min-w-0 overflow-hidden">
        <span className="flex w-40 items-center whitespace-nowrap transition-[opacity,translate] duration-200 ease-out group-data-[collapsed]/nav:-translate-x-1 group-data-[collapsed]/nav:opacity-0 motion-reduce:transition-none">
          {children}
        </span>
      </span>
    </span>
  );
}

export interface SidebarNavProps extends React.ComponentProps<"nav"> {
  /** Collapse to an icon rail; labels slide out and titles take over. */
  collapsed?: boolean;
}

/**
 * App sidebar with collapsible groups (grid-template-rows 0fr↔1fr), a
 * measured indicator that slides along the left edge to the active item,
 * badge counts, and a collapsed icon-rail mode where labels fade/slide out
 * and native titles stand in as tooltips.
 */
export function SidebarNav({
  collapsed = false,
  className,
  children,
  ...props
}: SidebarNavProps) {
  const navRef = React.useRef<HTMLElement>(null);
  const indicatorRef = React.useRef<HTMLSpanElement>(null);
  const hasPositioned = React.useRef(false);

  const position = React.useCallback((animate: boolean) => {
    const nav = navRef.current;
    const indicator = indicatorRef.current;
    if (!nav || !indicator) return;
    const active = nav.querySelector<HTMLElement>('[data-active="true"]');
    const closedGroup = active?.closest<HTMLElement>(
      '[data-group-content][data-open="false"]',
    );
    if (!active || closedGroup) {
      indicator.style.opacity = "0";
      return;
    }
    let top = 0;
    let node: HTMLElement | null = active;
    while (node && node !== nav) {
      top += node.offsetTop;
      node = node.offsetParent instanceof HTMLElement ? node.offsetParent : null;
    }
    if (!animate) indicator.style.transitionProperty = "none";
    indicator.style.opacity = "1";
    indicator.style.transform = `translateY(${
      top + (active.offsetHeight - 16) / 2
    }px)`;
    if (!animate) {
      void indicator.offsetWidth;
      indicator.style.transitionProperty = "";
    }
  }, []);

  // Re-measure after every render; the first placement snaps into position.
  React.useLayoutEffect(() => {
    position(hasPositioned.current);
    hasPositioned.current = true;
  });

  // Group collapse/expand changes offsets over time — chase them smoothly.
  React.useEffect(() => {
    const nav = navRef.current;
    if (!nav) return;
    const observer = new ResizeObserver(() => position(true));
    observer.observe(nav);
    return () => observer.disconnect();
  }, [position]);

  const contextValue = React.useMemo(() => ({ collapsed }), [collapsed]);

  return (
    <nav
      ref={navRef}
      data-slot="sidebar-nav"
      data-collapsed={collapsed || undefined}
      className={cn(
        "group/nav relative flex w-fit flex-col gap-3 p-2",
        className,
      )}
      {...props}
    >
      <span
        ref={indicatorRef}
        aria-hidden
        className="pointer-events-none absolute top-0 left-0 h-4 w-0.5 rounded-full bg-foreground opacity-0 [transition-property:transform,opacity] duration-[250ms] ease-out motion-reduce:transition-none"
      />
      <SidebarNavContext.Provider value={contextValue}>
        {children}
      </SidebarNavContext.Provider>
    </nav>
  );
}

export interface SidebarNavGroupProps extends React.ComponentProps<"div"> {
  label: string;
  defaultOpen?: boolean;
}

export function SidebarNavGroup({
  label,
  defaultOpen = true,
  className,
  children,
  ...props
}: SidebarNavGroupProps) {
  const { collapsed } = useSidebarNav();
  const [open, setOpen] = React.useState(defaultOpen);
  const contentId = React.useId();
  // The icon rail keeps every destination reachable, so collapsed groups
  // are forced open.
  const isOpen = collapsed || open;

  return (
    <div className={cn("flex flex-col", className)} {...props}>
      <button
        type="button"
        aria-expanded={isOpen}
        aria-controls={contentId}
        tabIndex={collapsed ? -1 : 0}
        onClick={() => setOpen((prev) => !prev)}
        className="relative flex h-7 items-center rounded-md px-2 text-[11px] font-medium tracking-wider text-muted-foreground/80 uppercase transition-colors duration-150 ease-out select-none after:absolute after:inset-x-0 after:-inset-y-1 hover:text-foreground"
      >
        <span
          aria-hidden
          className="pointer-events-none absolute inset-x-2 top-1/2 h-px bg-border opacity-0 transition-opacity duration-150 ease-out group-data-[collapsed]/nav:opacity-100 group-data-[collapsed]/nav:delay-150"
        />
        <CollapsingLabel>
          <span className="min-w-0 flex-1 truncate text-left">{label}</span>
          <ChevronDown
            aria-hidden
            className={cn(
              "ml-auto size-3 shrink-0 transition-transform duration-200 ease-out motion-reduce:transition-none",
              !open && "-rotate-90",
            )}
          />
        </CollapsingLabel>
      </button>
      <div
        id={contentId}
        data-group-content=""
        data-open={isOpen}
        className="grid [grid-template-rows:0fr] transition-[grid-template-rows] duration-[250ms] ease-out data-[open=true]:[grid-template-rows:1fr] motion-reduce:transition-none"
      >
        <div className="min-h-0 overflow-hidden">
          <div className="flex flex-col gap-0.5 pt-0.5">{children}</div>
        </div>
      </div>
    </div>
  );
}

export interface SidebarNavItemProps extends React.ComponentProps<"a"> {
  icon?: React.ReactNode;
  /** Count or short text rendered at the trailing edge. */
  badge?: React.ReactNode;
  active?: boolean;
}

export function SidebarNavItem({
  icon,
  badge,
  active = false,
  className,
  children,
  ...props
}: SidebarNavItemProps) {
  const { collapsed } = useSidebarNav();
  return (
    <a
      data-slot="sidebar-nav-item"
      data-active={active || undefined}
      title={
        collapsed && typeof children === "string" ? children : undefined
      }
      className={cn(
        "relative flex h-8 items-center rounded-md px-2 text-sm font-medium text-muted-foreground transition-colors duration-150 ease-out hover:bg-accent/60 hover:text-foreground data-active:bg-accent data-active:text-accent-foreground",
        className,
      )}
      {...props}
    >
      <span
        aria-hidden
        className="flex size-5 shrink-0 items-center justify-center [&_svg]:size-4"
      >
        {icon}
      </span>
      <CollapsingLabel>
        <span className="min-w-0 flex-1 truncate pl-2">{children}</span>
        {badge != null && (
          <span className="ml-auto inline-flex h-5 min-w-5 items-center justify-center rounded-full bg-secondary px-1.5 text-[11px] font-medium text-muted-foreground tabular-nums">
            {badge}
          </span>
        )}
      </CollapsingLabel>
      {badge != null && (
        <span
          aria-hidden
          className="absolute top-1.5 right-1.5 size-1 rounded-full bg-foreground/50 opacity-0 transition-opacity duration-150 ease-out group-data-[collapsed]/nav:opacity-100 group-data-[collapsed]/nav:delay-150"
        />
      )}
    </a>
  );
}