# Outline

Links to the sections of a page, marking the one in view.

Source: [outline.tsx](https://github.com/jakejarvis/jarvis-ui/blob/main/src/components/ui/outline.tsx)

```tsx
import { Outline, OutlineItem } from "@/components/ui/outline";

const sections = [
  {
    id: "guide-overview",
    title: "Overview",
    level: 1,
    body: [
      "This guide takes a new project from an empty folder to a deployed site. Each step builds on the one before it, so work through them in order the first time.",
      "Most steps take a few minutes. Deploying takes longer the first time, while the build cache warms up.",
    ],
  },
  {
    id: "guide-installing",
    title: "Installing",
    level: 1,
    body: [
      "Install the command-line tool with your package manager, then sign in. The tool remembers your account, so you only sign in once on each computer.",
      "Run the init command in an empty folder. It asks for a project name and writes a config file with sensible defaults.",
    ],
  },
  {
    id: "guide-configuring",
    title: "Configuring",
    level: 1,
    body: [
      "The config file sets where the site builds from and which commands run. Leave the defaults alone until something needs to change.",
    ],
  },
  {
    id: "guide-environment",
    title: "Environment variables",
    level: 2,
    body: [
      "Keep secrets out of the config file. Add them in the dashboard instead, where each one can differ between preview and production.",
      "A build reads variables when it starts, so redeploy after changing one.",
    ],
  },
  {
    id: "guide-deploying",
    title: "Deploying",
    level: 1,
    body: [
      "Push to the main branch to deploy to production. Every other branch gets a preview address of its own, which updates on each push.",
      "If a deploy fails, its log shows the step that failed and the output just before it.",
    ],
  },
];

export default function OutlineArticle() {
  return (
    <div className="grid w-full max-w-xl grid-cols-1 gap-6 sm:grid-cols-[minmax(0,1fr)_9rem]">
      <article className="h-72 overflow-y-auto rounded-lg border-[0.5px] border-border-muted px-5 pb-5">
        {sections.map((section) => (
          <section key={section.id}>
            <h3
              id={section.id}
              className="mt-5 mb-2 scroll-mt-5 text-sm font-medium text-foreground"
            >
              {section.title}
            </h3>
            {section.body.map((paragraph) => (
              <p key={paragraph} className="mb-3 text-sm text-muted-foreground">
                {paragraph}
              </p>
            ))}
          </section>
        ))}
      </article>
      <Outline label="Contents" className="max-sm:order-first">
        {sections.map((section) => (
          <OutlineItem key={section.id} href={`#${section.id}`} level={section.level === 2 ? 2 : 1}>
            {section.title}
          </OutlineItem>
        ))}
      </Outline>
    </div>
  );
}
```

## Installation

Add the registry, once per project:

```bash
npx shadcn@latest registry add @jarvis=https://ui.jarv.is/r/{name}.json
```

Then add the component:

```bash
npx shadcn@latest add @jarvis/outline
```

## Usage

```tsx
import { Outline, OutlineItem } from "@/components/ui/outline";

export function OnThisPage() {
  return (
    <Outline label="On this page">
      <OutlineItem href="#installation">Installation</OutlineItem>
      <OutlineItem href="#usage">Usage</OutlineItem>
      <OutlineItem href="#examples">Examples</OutlineItem>
      <OutlineItem href="#sizes" level={2}>
        Sizes
      </OutlineItem>
    </Outline>
  );
}
```

Link each `OutlineItem` to a heading's id. As the page scrolls, the outline marks the last heading
that has reached the top, or the last one once the page can't scroll further, and the line beside
it slides to follow. It follows whichever element the headings scroll in, the window or a panel,
and checks again when its links change.

Clicking a link scrolls its heading smoothly into place and moves focus to it, so Tab carries on
from that section. Scrolling the headings yourself during the animation takes over from it, and so
does the scroll stopping anywhere else. When the headings scroll with the window, the heading's id
goes in the address once it arrives. A heading lands below the scroller's `scroll-padding-top` and
its own `scroll-margin-top`, and it counts as reached at the same point, so make room there for any
header fixed over the content.

A link's `value` is the part of its `href` after `#`, unless you pass one. Only links to the
current page scroll this way: other links, and links whose `onClick` calls `preventDefault`, such
as a router's link passed to `render`, navigate as they would anyway, and the outline only marks
them.

`label` names the outline and shows above the links. Without it, give `Outline` an `aria-label`.
This site's own On this page column is an `Outline`.

## Composition

- `Outline`
  - `OutlineItem`

## Examples

### Levels

`level` indents a link under the one before it, from `1` to `4`.

```tsx
import * as React from "react";

import { Outline, OutlineItem } from "@/components/ui/outline";

export default function OutlineLevels() {
  const [value, setValue] = React.useState("color");

  return (
    <Outline aria-label="Design system" value={value} onValueChange={setValue} className="w-48">
      <OutlineItem href="#introduction">Introduction</OutlineItem>
      <OutlineItem href="#foundations">Foundations</OutlineItem>
      <OutlineItem href="#tokens" level={2}>
        Tokens
      </OutlineItem>
      <OutlineItem href="#color" level={3}>
        Color
      </OutlineItem>
      <OutlineItem href="#spacing" level={3}>
        Spacing
      </OutlineItem>
      <OutlineItem href="#typography" level={2}>
        Typography
      </OutlineItem>
      <OutlineItem href="#components">Components</OutlineItem>
    </Outline>
  );
}
```

### Controlled

Pass `value` and `onValueChange` to choose the marked section yourself. The outline still calls
`onValueChange` as the page scrolls and when a link is clicked.

```tsx
import * as React from "react";

import { Button } from "@/components/ui/button";
import { Outline, OutlineItem } from "@/components/ui/outline";

const steps = [
  { value: "summary", title: "Summary" },
  { value: "details", title: "Details" },
  { value: "results", title: "Results" },
  { value: "next-steps", title: "Next steps" },
];

export default function OutlineControlled() {
  const [value, setValue] = React.useState("details");
  const index = steps.findIndex((step) => step.value === value);

  return (
    <div className="flex w-48 flex-col gap-4">
      <Outline aria-label="Report" value={value} onValueChange={setValue}>
        {steps.map((step) => (
          <OutlineItem key={step.value} href={`#${step.value}`}>
            {step.title}
          </OutlineItem>
        ))}
      </Outline>
      <div className="flex gap-2">
        <Button
          variant="secondary"
          size="sm"
          disabled={index === 0}
          onClick={() => setValue(steps[index - 1].value)}
        >
          Previous
        </Button>
        <Button
          variant="secondary"
          size="sm"
          disabled={index === steps.length - 1}
          onClick={() => setValue(steps[index + 1].value)}
        >
          Next
        </Button>
      </div>
    </div>
  );
}
```

## Props

| Part | Extends |
| --- | --- |
| `Outline` | `<nav>` |
| `OutlineItem` | `<a>` |

The tables leave out `className`, `style` and `render`. Parts built on Base UI take them as its [styling](https://base-ui.com/react/handbook/styling) and [composition](https://base-ui.com/react/handbook/composition) guides describe.

### Outline

| Prop             | Type                        | Description                                                                                      |
| ---------------- | --------------------------- | ------------------------------------------------------------------------------------------------ |
| `label?`         | `React.ReactNode`           | A visible title above the links, which also names the navigation. Without it, pass `aria-label`. |
| `value?`         | `string \| null`            | The id of the active section. Without it, the outline follows the scroll position.               |
| `onValueChange?` | `((value: string) => void)` | Called with a section's id when it scrolls into place or its link is clicked.                    |

### OutlineItem

| Prop     | Type               | Description                                                                                           |
| -------- | ------------------ | ----------------------------------------------------------------------------------------------------- |
| `value?` | `string`           | The id of the section it links to. Defaults to the part of `href` after `#`.                          |
| `level?` | `1 \| 2 \| 3 \| 4` | How deep the section is, from `1` for the top level to `4`. Each level indents the link. Default: `1` |
