# Reading TOC (Moonarc)

A table of contents that lights up as you read. The list is server-rendered from items (Astro's own headings shape works), the links are plain anchors, and one passive scroll listener sets aria-current on the link of the last heading above the reading line, which is the only thing the script does. The indicator is CSS from that attribute. Works inside a scrolling box as well as on the page, follows jumps from its own links, and is dropped before every navigation.

- Import: `import ReadingToc from '@moonarc/core/ReadingToc'`
- Install: `npx astro add moonarc` · copy-paste: `npx shadcn@latest add https://moonarc.dev/r/reading-toc.json`
- Tier B · category scroll · trigger scroll
- Readout: `<ReadingToc for="article" items={headings}>`
- Browser support: widely (every browser)
- Measured cost: 899 B raw JS · 547 B gzip · + runtime (with dependencies 2.6 kB raw; CSS 5.0 kB raw)
- Page: https://moonarc.dev/components/reading-toc/

## Props

| Prop | Type | Default | Description |
|---|---|---|---|
| `items` | `{ slug: string; text: string; depth?: number }[]` | none | The headings; depth indents (2 for h2, 3 for h3). |
| `for` | `string` | none | Selector of the article the headings live in. |
| `headings` | `string` | `'h2, h3'` | Heading selector inside the article; only headings with an id count. |
| `offset` | `number` | `96` | Distance from the top of the scrollport where a heading becomes current, in px; usually your sticky header's height. |
| `title` | `string` | `'On this page'` | Heading over the list and the nav's label. |

## Usage

```astro
---
const { Content, headings } = await render(post);
---
<aside><ReadingToc for="#post" items={headings} offset={96} /></aside>
<article id="post"><Content /></article>
```

## Reduced motion

The indicator and the link's opacity switch at once.

## With ClientRouter

Bound through the shared runtime: the scroll listener is added after every navigation and removed before the swap (the binding's abort signal), so five round trips leave one listener, not six.

## Craft

- The current heading is the last one whose top is above the offset line, recomputed on every scroll (at most once a frame, a rect read per heading): a reader between two headings is in the earlier section, which is the one the indicator should show. An IntersectionObserver on a band under the line looked cheaper and missed every jump, since a heading sent from below the fold to the top by a link never crosses the band.
- The listener sits on the nearest ancestor of the article that really scrolls (its content overflows it), so the same component works in a page, a docs shell with its own scroller and the catalogue tile, and a page wrapper with overflow-x: hidden (which computes overflow-y to auto) is passed over.
- Only aria-current is written. The indicator (a bar that grows from the middle) and the link's full opacity are CSS from that attribute, so the accessible state and the visible state cannot disagree.
- Links stay anchors: without the script the TOC still jumps to the section and :target still works.

## Replaces

- TableOfContents (Fumadocs)
- scrollspy (Bootstrap)
- toc highlight (Starlight)

## Source

```astro
---
/**
 * ReadingToc — a table of contents that lights up as you read. The list is
 * server-rendered from `items` (Astro's own `headings` shape works), the
 * links are plain anchors, and one passive scroll listener sets
 * aria-current on the link of the last heading above the reading line —
 * the only thing a script does. The indicator is CSS from that attribute.
 * The listener sits on the nearest ancestor of the article that really
 * scrolls, so it works inside a scrolling box as well as on the page, and
 * it is dropped before every ClientRouter swap.
 */
import type { HTMLAttributes } from 'astro/types';

interface Item {
  slug: string;
  text: string;
  /** 2 for h2, 3 for h3 … */
  depth?: number;
}

interface Props extends HTMLAttributes<'nav'> {
  items: Item[];
  /** Selector of the article the headings live in. */
  for: string;
  /** Heading selector inside the article. */
  headings?: string;
  /** Distance from the top of the scrollport where a heading counts as current, in px. */
  offset?: number;
  title?: string;
}

const { items, for: target, headings = 'h2, h3', offset = 96, title = 'On this page', class: className, ...rest } = Astro.props;
const min = Math.min(...items.map((i) => i.depth ?? 2));
---

<nav class:list={['ma-toc', className]} data-ma-toc data-for={target} data-headings={headings} data-offset={offset} aria-label={title} {...rest}>
  {title && <p class="ma-toc__title">{title}</p>}
  <ul class="ma-toc__list">
    {items.map((i) => (
      <li class="ma-toc__item" style={`--ma-toc-d:${(i.depth ?? 2) - min}`}>
        <a class="ma-toc__link" href={`#${i.slug}`}>{i.text}</a>
      </li>
    ))}
  </ul>
</nav>

<style is:global>
  @layer components {
    :where(.ma-toc) {
      font-size: 0.875rem;
    }
    :where(.ma-toc__title) {
      margin: 0 0 0.5rem;
      font-size: 0.75em;
      letter-spacing: 0.08em;
      text-transform: uppercase;
      opacity: var(--ma-dim, 0.7);
    }
    :where(.ma-toc__list) {
      list-style: none;
      margin: 0;
      padding: 0;
      border-left: 1px solid var(--ma-edge);
    }
    :where(.ma-toc__item) {
      margin: 0;
      padding: 0;
    }
    :where(.ma-toc__link) {
      position: relative;
      display: block;
      padding: 0.3em 0 0.3em calc(0.9em + var(--ma-toc-d, 0) * 0.9em);
      color: inherit;
      text-decoration: none;
      opacity: var(--ma-dim, 0.7);
      transition: opacity var(--ma-duration-fast) var(--ma-ease-out);
    }
    /* the indicator: a bar on the list's left edge that grows from its middle */
    :where(.ma-toc__link)::before {
      content: '';
      position: absolute;
      inset: 0.2em auto 0.2em -1px;
      width: 2px;
      border-radius: 999px;
      background: currentColor;
      scale: 1 0;
      transition: scale var(--ma-duration) var(--ma-ease);
    }
    :where(.ma-toc__link:hover) {
      opacity: 0.9;
    }
    :where(.ma-toc__link[aria-current]) {
      opacity: 1;
    }
    :where(.ma-toc__link[aria-current])::before {
      scale: 1 1;
    }
    :where(.ma-toc__link:focus-visible) {
      outline: 2px solid currentColor;
      outline-offset: 2px;
      border-radius: 0.25em;
    }
    @media (prefers-reduced-motion: reduce) {
      :where(.ma-toc__link),
      :where(.ma-toc__link)::before {
        transition: none;
      }
    }
  }
</style>

<script>
  import { onMount } from '../lib/runtime';

  onMount<HTMLElement>('[data-ma-toc]', (nav, { signal }) => {
    const article = nav.dataset.for ? document.querySelector(nav.dataset.for) : null;
    if (!article) return;
    const heads = Array.from(article.querySelectorAll<HTMLElement>(nav.dataset.headings!)).filter((h) => h.id);
    const links = new Map(Array.from(nav.querySelectorAll<HTMLAnchorElement>('a[href^="#"]')).map((a) => [decodeURIComponent(a.hash.slice(1)), a]));
    if (!heads.length) return;
    // the nearest ancestor that really scrolls decides the line; null is the viewport. overflow-x: hidden alone computes
    // overflow-y to auto as well, so a page wrapper that only stops sideways scroll also matches; its content never
    // overflows it, which is how it is told apart
    let root: Element | null = article.parentElement;
    while (root && root !== document.body) {
      const o = getComputedStyle(root).overflowY;
      if ((o === 'auto' || o === 'scroll') && root.scrollHeight > root.clientHeight) break;
      root = root.parentElement;
    }
    if (root === document.body) root = null;
    const offset = +nav.dataset.offset!;
    let current: HTMLElement | null = null;
    const mark = (h: HTMLElement | null) => {
      if (h === current) return;
      current = h;
      for (const [id, a] of links) {
        if (h && id === h.id) a.setAttribute('aria-current', 'true');
        else a.removeAttribute('aria-current');
      }
    };
    // the heading nearest the reading line: the last one whose top is above it
    const pick = () => {
      const top = root ? root.getBoundingClientRect().top : 0;
      let hit: HTMLElement | null = null;
      for (const h of heads) if (h.getBoundingClientRect().top - top <= offset + 1) hit = h;
      mark(hit ?? heads[0]!);
    };
    // every scroll re-reads the line (scroll fires at most once a frame): a jump from a link, a key or the scrollbar lands
    // on the right heading too, where an observer on a band below the line saw nothing cross it
    ((root ?? window) as EventTarget).addEventListener('scroll', pick, { signal, passive: true });
    pick();
  });
</script>

```
