Skip to content
goodtabs UI
BrowseBrowse components

Nav tree

A nested collapsible tree of links for docs, with chevrons and hairline guides.

npx shadcn@latest add https://ui.goodtabs.co/r/nav-tree.json

With the @goodtabs namespace in components.json: npx shadcn@latest add @goodtabs/nav-tree

Getting started is open because it holds the active page. Components is closed.

Two levels deep

A branch that is also a link, with groups nested inside it.

All open

Every branch starts open with defaultOpen, no active page.

Usage

import { NavTree } from "@/components/ui/nav-tree"

<NavTree
  activeHref={pathname}
  items={[
    {
      label: "Getting started",
      children: [
        { label: "Introduction", href: "/docs" },
        { label: "Installation", href: "/docs/installation" },
      ],
    },
    { label: "Changelog", href: "/docs/changelog" },
  ]}
/>

Props

PropTypeDefaultDescription
items{ label: string; href?: string; children?: NavTreeItem[]; defaultOpen?: boolean }[]The tree, any depth. Items with children are branches, the rest are links.
activeHrefstringThe current page. Sets aria-current and the active fill, and opens its branches.
aria-labelstring"Pages"Accessible name of the nav landmark.

Source

nav-tree/nav-tree.tsx
import * as React from "react";
import Link from "next/link";
import { ChevronRightIcon } from "lucide-react";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { cn } from "@/lib/utils";

export type NavTreeItem = {
  label: string;
  /** Makes the row a link. A branch with an href gets a separate chevron button. */
  href?: string;
  /** Nested items. Any depth. */
  children?: NavTreeItem[];
  /** Start open. Branches that contain the active href open on their own. */
  defaultOpen?: boolean;
};

type NavTreeProps = Omit<React.ComponentProps<"nav">, "children"> & {
  /** The tree, top level first. */
  items: NavTreeItem[];
  /** The current page. Sets aria-current and the active fill on the matching leaf. */
  activeHref?: string;
};

const row =
  "flex h-7 min-w-0 items-center gap-1.5 rounded-sm px-2 text-left text-sm text-muted-foreground transition-colors duration-100 ease-out outline-none hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/50";

function containsActive(item: NavTreeItem, activeHref?: string): boolean {
  if (!activeHref) return false;
  if (item.href === activeHref) return true;
  return item.children?.some((child) => containsActive(child, activeHref)) ?? false;
}

/**
 * A nested collapsible tree for docs. Branches toggle with a chevron, leaves are links.
 * The branch holding the active page opens by default.
 */
export function NavTree({ items, activeHref, className, "aria-label": ariaLabel = "Pages", ...props }: NavTreeProps) {
  return (
    <nav data-slot="nav-tree" aria-label={ariaLabel} className={cn("text-sm", className)} {...props}>
      <NavTreeList items={items} activeHref={activeHref} />
    </nav>
  );
}

function NavTreeList({ items, activeHref, nested }: { items: NavTreeItem[]; activeHref?: string; nested?: boolean }) {
  return (
    <ul className={cn("m-0 flex list-none flex-col gap-px p-0", nested && "mt-px ml-3.5 border-l pl-2")}>
      {items.map((item) => (
        <li key={item.href ?? item.label} className="min-w-0">
          {item.children && item.children.length > 0 ? (
            <NavTreeBranch item={item} activeHref={activeHref} />
          ) : (
            <NavTreeLeaf item={item} active={item.href !== undefined && item.href === activeHref} />
          )}
        </li>
      ))}
    </ul>
  );
}

function NavTreeLeaf({ item, active }: { item: NavTreeItem; active: boolean }) {
  if (!item.href) {
    return <span className={cn(row, "text-foreground/70")}>{item.label}</span>;
  }
  return (
    <Link
      href={item.href}
      aria-current={active ? "page" : undefined}
      className={cn(row, active && "bg-muted font-medium text-foreground")}
    >
      <span className="truncate">{item.label}</span>
    </Link>
  );
}

function NavTreeBranch({ item, activeHref }: { item: NavTreeItem; activeHref?: string }) {
  const open = item.defaultOpen ?? containsActive(item, activeHref);
  const chevron = (
    <ChevronRightIcon
      aria-hidden="true"
      className="size-3.5 shrink-0 transition-transform duration-150 ease-out group-data-open/nav-tree-trigger:rotate-90 motion-reduce:transition-none"
    />
  );

  return (
    <Collapsible defaultOpen={open}>
      {item.href ? (
        <div className="flex items-center gap-px">
          <Link
            href={item.href}
            aria-current={item.href === activeHref ? "page" : undefined}
            className={cn(row, "flex-1", item.href === activeHref && "bg-muted font-medium text-foreground")}
          >
            <span className="truncate">{item.label}</span>
          </Link>
          <CollapsibleTrigger
            aria-label={`Toggle ${item.label}`}
            className="group/nav-tree-trigger inline-flex size-7 shrink-0 items-center justify-center rounded-sm text-muted-foreground transition-colors duration-100 ease-out outline-none hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/50"
          >
            {chevron}
          </CollapsibleTrigger>
        </div>
      ) : (
        <CollapsibleTrigger className={cn(row, "group/nav-tree-trigger w-full")}>
          {chevron}
          <span className="truncate">{item.label}</span>
        </CollapsibleTrigger>
      )}
      <CollapsibleContent>
        <NavTreeList items={item.children ?? []} activeHref={activeHref} nested />
      </CollapsibleContent>
    </Collapsible>
  );
}

For AI agents

Hand your agent this page, the markdown at /components/nav-tree/prompt.md, or the Copy AI prompt button above.

When to use

  • The sidebar of a docs site with sections and pages
  • Settings or admin areas with two or three levels
  • Any list of links that reads better folded than flat

When not to use

  • For a flat list of up to eight links, plain links are enough
  • For file browsers that need arrow key navigation and selection, build a real ARIA tree
  • For top level site navigation, use the navbar or mega menu

Agent notes

  • Server component: pass activeHref from usePathname in a client wrapper or from the page props
  • A branch that contains the active href opens on its own; defaultOpen overrides that per branch
  • A branch with an href renders a link plus a separate chevron button labelled Toggle, so both are reachable by keyboard
  • Rows are 28px and the tree has no width of its own: set it on className, usually w-56 to w-64