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
| Prop | Type | Default | Description |
|---|---|---|---|
| items | { label: string; href?: string; children?: NavTreeItem[]; defaultOpen?: boolean }[] | The tree, any depth. Items with children are branches, the rest are links. | |
| activeHref | string | The current page. Sets aria-current and the active fill, and opens its branches. | |
| aria-label | string | "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