---
title: Doc Aside
description: An on-page table of contents that is a rail on desktop and a collapsed row on mobile.
source: https://adamjurek.com/components/doc-aside
---

## Home


Every heading in this page's sidebar comes from the Markdown itself. There is
no list to maintain - write an `h2` and it appears.

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

## What it does

A sticky rail beside the prose on desktop, a narrower rail on tablet, and one
collapsed row above the article on a phone. A contents list that pushes the
article down by ten lines is worse than no contents list.

## Why the collapse is CSS

The mobile toggle is a checkbox and a label, not React state. A contents list
is the first thing a reader reaches for on a phone, and one built from state
does not work until hydration - which on a long document is exactly when it is
least likely to have happened.

The same markup is a plain list on desktop, because CSS hides the control
rather than the component rendering something different.

## The last heading problem

The highlight is computed from scroll position, not an `IntersectionObserver`,
and that is not a preference.

An observer with a top-band root margin never fires for the final heading: a
short last section means the page runs out of scroll before that heading
reaches the band, so the last item in the list can never highlight no matter
how far down you go.

Reading positions directly makes the case expressible - at the bottom of the
document, the last heading is what you are looking at, whether or not it
crossed the line.

> [!TIP] Scroll to the bottom of this page
> The last item in the sidebar highlights. That is the bug this component
> exists to not have.

## Accessibility

The checkbox is `sr-only`, not `display: none`, so it stays focusable and
announced - only its default appearance is hidden. Links carry
`aria-current="location"` when active, and the mobile targets are 44px tall.


## Install

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

### TanStack

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

### shadcn

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

### pnpm

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

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

## What you get

| | |
| --- | --- |
| Version | 0.1.0 |
| Category | docs · Navigation |
| Files | `doc-aside.tsx`, `headings.ts` |
| Dependencies | `@tanstack/markdown@0.0.13` |
| Tags | markdown, scroll, responsive |


## 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 { collectHeadings, DocAside } from "@sushindustries/ui";

export function Example({ source }: { source: string }) {
	return <DocAside headings={collectHeadings(source)} />;
}
```

## What you should see

A sticky list of every `h2` in `source`, in order. Scroll the article beside
it and whichever heading you are currently under highlights on its own,
including the last one - which is the one case a naive
`IntersectionObserver` approach gets wrong.

## If nothing happens

With fewer than `minHeadings` (2 by default) headings, `DocAside` renders
nothing at all - that is correct, not broken, a sidebar with one link is
navigation to where the reader already is. Collect `headings` in a route
loader rather than in the component; parsing Markdown for `h2`s inside the
component itself works, but repeats on every render for no reason.


## Guides


## Composing it

It expects to sit beside the prose it is a contents list for, in a two-column
layout with its own scroll region - it is `position: sticky` internally, so
its parent needs to be as tall as the article for the stickiness to have
anywhere to travel. A `DocAside` dropped into a short parent just sits at the
top and never appears to track anything.

## Adding a footer

```tsx
<DocAside headings={headings} footer={<FeedbackButtons />} />
```

Anything passed as `footer` renders under the contents list, inside the same
rail - a "was this helpful" control, a copy-page-as-markdown action, whatever
belongs within reach of someone who has already found this sidebar.


## API


<!-- generated:api -->

## Props

| Prop | Type | Default | Does |
| --- | --- | --- | --- |
| `headings` | `readonly DocHeading[]` | - | The list to render. Collect it in a route loader, not in the component. |
| `label?` | `string` | `"On this page"` | Heading on desktop, button text on mobile. |
| `minHeadings?` | `number` | `2` | Renders nothing below this count. One heading is not a contents list. |
| `footer?` | `ReactNode` | - | Rendered under the contents list: feedback buttons, a copy action, whatever the page wants within reach of a reader who is already here. |

<!-- /generated:api -->

## Getting the headings

```ts
import { collectHeadings } from "@sushindustries/ui";

const headings = collectHeadings(markdownSource); // h2 by default
const h3s = collectHeadings(markdownSource, 3);
```

Collect them in a route loader, not in the component. Parsing is synchronous
and the loader runs on the server, so the contents list ends up in the cached
HTML instead of being work the browser repeats on every render.

## Why `minHeadings` defaults to 2

One heading is not a contents list, it is the page. Rendering a sidebar with a
single link is offering navigation to where the reader already is.


## 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-aside" 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 { collectHeadings, DocAside } from "@sushindustries/ui";
import { MarkdownView } from "@sushindustries/ui";

export function DocPage({ source }: { source: string }) {
	const headings = collectHeadings(source);

	return (
		<div className="doc-layout">
			<article>
				<MarkdownView source={source} />
			</article>
			<DocAside headings={headings} />
		</div>
	);
}
```

## What this example is not

Not a single parse. `collectHeadings` parses `source` a second time,
separately from `MarkdownView`'s own render - deliberately synchronous and
server-side, so the cost lands once in the cached HTML rather than on every
client render.
