---
title: Doc Nav
description: The left rail of a documentation page - the sections of a library, the elements in each, and the one that is open.
source: https://adamjurek.com/components/doc-nav
---

## Home


The rail that says where you are. Sections come in as data, links are rendered
by the host, and the open item is marked and scrolled to. On a wide screen it
is a sticky column; below that it folds into one row above the document that
opens on tap.

<!-- ::start:showcase demo="doc-nav" height="420" -->
<!-- ::end:showcase -->

## Why the collapse is CSS

The toggle is a checkbox and a label, not React state. A reader who has landed
on the wrong element wants the next one immediately, and a control built from
state does nothing until hydration. The same markup is a static rail on a wide
screen - CSS hides the control rather than the component rendering something
different.

> [!NOTE] Collapsed on a tablet, not hidden
> The tab bar above a document only moves between that element's own sections.
> This is the one thing on the page that gets you to the next element, so it
> keeps a row rather than disappearing between 861px and 1199px.

## Scrolling, carefully

The open item is brought into view by writing `scrollTop` on the rail.
`scrollIntoView` scrolls every scrollable ancestor, so landing on an element
two thirds down the list would also scroll the document past its own title
before the reader had seen it.


## Install

<!-- ::start:tabs -->

### TanStack

```shell
tanstack add https://adamjurek.com/r/tanstack/doc-nav.json
```

### shadcn

```shell
pnpm dlx shadcn@latest add https://adamjurek.com/r/shadcn/doc-nav.json
```

### pnpm

```shell
pnpm add @sushindustries/ui @sushindustries/atoms
```

<!-- ::end:tabs -->

## What you get

| | |
| --- | --- |
| Version | 0.1.0 |
| Category | docs · Navigation |
| Files | `doc-nav.tsx` |
| Dependencies | None |
| Also installs | `icon` |
| Tags | navigation, responsive |

> [!NOTE] No runtime dependencies
> It brings nothing with it beyond the stylesheet.

## Get Started


Install commands are on Home, attached from the registry - they are not written
here, because a second copy is a copy that goes stale. This tab starts after the
install worked.

## Use it

```tsx
import { DocNav } from "@sushindustries/ui";

export function Example() {
	return (
		<DocNav
			active="doc-nav"
			sections={[
				{
					id: "docs",
					label: "Docs",
					icon: "book",
					items: [
						{ id: "doc-aside", label: "Doc Aside", href: "/components/doc-aside" },
						{ id: "doc-nav", label: "Doc Nav", href: "/components/doc-nav" },
					],
				},
			]}
			renderLink={({ href, className, children, ...rest }) => (
				<a href={href} className={className} {...rest}>
					{children}
				</a>
			)}
		/>
	);
}
```

## What you should see

A grouped, sticky list of every section and its elements, with "Doc Nav"
marked as the current one - a different colour and `aria-current="page"` on
its link. On a wide screen it is a plain column; narrow the window past
1200px and it folds into a single row that opens on tap.

## If nothing happens

A `sections` array where every section has an empty `items` renders nothing
at all - an empty category is treated as one nobody has filled yet, not a
heading worth showing. `renderLink` is required and does the actual link
rendering; skip it and nothing renders, since this component has no built-in
fallback to a plain anchor the way some of its siblings do.


## Guides


## Composing it

```tsx
renderLink={({ id, className, children, ...rest }) => (
	<Link to="/components/$slug" params={{ slug: id }} className={className} {...rest}>
		{children}
	</Link>
)}
```

`id` arrives beside the resolved `href` because a typed router needs the route
pattern and its params, not a finished path. `aria-current` rides in `rest`
for the open item, since this component cannot set an attribute on an element
it did not create. The row itself carries `data-active`, so the colour is
right even when a host drops what it is handed.


## API


<!-- generated:api -->

## Props

| Prop | Type | Default | Does |
| --- | --- | --- | --- |
| `sections` | `readonly DocNavSection[]` | - | The sections, in the order given. Collect them in a route loader, not here. |
| `active?` | `string` | - | The item that is open, so it can be marked and scrolled to. |
| `label?` | `string` | `"Library"` | Heading on desktop, button text once the rail is a collapsed row. |
| `renderLink` | `(props: { id: string; href: string; className: string; "aria-current"?: "page"; children: ReactNode; }) => ReactNode` | - | Renders each link, so the host can use its router's Link. `id` is passed alongside the plain href because a typed router needs the route pattern and its params, not a path that has already been resolved - handing `Link` a resolved `/components/reveal` gets an anchor with the right href whose click is intercepted and then silently fails to match `/components/$slug`. The href stays for hosts that just want an anchor. |

<!-- /generated:api -->

## Notes

`sections` is `DocNavSection[]`, and a section is `{ id, label, icon?, items }`
where an item is `{ id, label, href }`. `icon` is an `IconName`, so a section
with no glyph is one that omits the key rather than one that passes an empty
string.

```tsx
const sections: DocNavSection[] = [
	{
		id: "docs",
		label: "Docs",
		icon: "book",
		items: [{ id: "doc-nav", label: "Doc Nav", href: "/components/doc-nav" }],
	},
];
```

A section with no items renders nothing, and a rail with no filled sections
renders nothing at all. An empty category is a group nobody has written yet,
not a heading to look at.

An `active` that matches no item is not an error. Nothing is marked and the
rail does not scroll, which is the right answer for a page that is inside the
library but is not one of its elements.

Sections with no items are dropped before rendering, and a set where every
section is empty renders nothing at all - so a rail waiting on its data leaves
no empty box behind.


## Examples


Examples are the tab where the component is shown doing a job, not
demonstrating a prop. The API tab already lists the props.

<!-- ::start:showcase demo="doc-nav" height="420" -->
<!-- ::end:showcase -->

Press Compare. The frames are real viewports, so a layout that breaks at 320
breaks here too rather than in somebody's hands.

## In a page

```tsx
import { Link, useParams } from "@tanstack/react-router";
import { DocNav, type DocNavSection } from "@sushindustries/ui";

export function ComponentLayout({ sections }: { sections: DocNavSection[] }) {
	const { slug } = useParams({ from: "/components/$slug" });

	return (
		<div className="doc-layout" data-nav="true">
			<DocNav
				sections={sections}
				active={slug}
				renderLink={({ id, className, children, ...rest }) => (
					<Link
						to="/components/$slug"
						params={{ slug: id }}
						className={className}
						{...rest}
					>
						{children}
					</Link>
				)}
			/>
			{/* the element's own tabs and content */}
		</div>
	);
}
```

## What this example is not

Not the whole documentation shell. `sections` still has to be assembled from
somewhere - on this site, from `registry.ts` and the categories it declares -
this example only shows the rail once it already has that data.
