Skip to content

Components / Scroll

Reading TOC

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.

Live demo

Install

Each heading becomes current when it crosses a line near the top of this box; the scroll listener sits on this box.

Only aria-current is written; the bar is CSS.

Tokens

Each heading becomes current when it crosses a line near the top of this box; the scroll listener sits on this box.

Only aria-current is written; the bar is CSS.

Runtime

Each heading becomes current when it crosses a line near the top of this box; the scroll listener sits on this box.

Only aria-current is written; the bar is CSS.

Ship

Each heading becomes current when it crosses a line near the top of this box; the scroll listener sits on this box.

Only aria-current is written; the bar is CSS.

The end.

Live, from the library itself: scroll, hover, navigate away and back. The demo is never gated.

Measured

JavaScript of its own

899 B raw

547 B gzip.

Uses the shared runtime (1.9 kB raw, once per site). With those included: 2.6 kB raw.

CSS 5.0 kB raw including the base tokens. Measured from a production build, and again in CI for every change that can move it.

Browser support

Baseline · widely available

every browser.

Install

One command adds the integration, the base tokens and every component. Then import what you use.

npx astro add moonarc
pnpm astro add moonarc
bunx astro add moonarc
src/pages/index.astro
---
import ReadingToc from '@moonarc/core/ReadingToc';
const { Content, headings } = await render(post);
---
<aside><ReadingToc for="#post" items={headings} offset={96} /></aside>
<article id="post"><Content /></article>
Copy it into your project instead (shadcn registry)

Owns the file, no dependency. The registry item also installs the shared runtime and the base tokens.

terminal
npx shadcn@latest add https://moonarc.dev/r/reading-toc.json

The CLI needs a components.json and the @/* alias, which Setup has. The file lands in src/components/moonarc/.

Source

The whole component. Self-contained styles in a cascade layer so your classes always win. If you paste it, also copy runtime.ts.

ReadingToc.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>

Props

PropTypeDefaultDescription
items{ slug: string; text: string; depth?: number }[]noneThe headings; depth indents (2 for h2, 3 for h3).
forstringnoneSelector of the article the headings live in.
headingsstring'h2, h3'Heading selector inside the article; only headings with an id count.
offsetnumber96Distance from the top of the scrollport where a heading becomes current, in px; usually your sticky header's height.
titlestring'On this page'Heading over the list and the nav's label.

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.

Why it is built this way

Replaces: TableOfContents (Fumadocs) · scrollspy (Bootstrap) · toc highlight (Starlight). See the migration table.

A hairline along the top of the viewport that fills as the page is read.

Chrome 115 · not Firefox · Safari 26scroll
npx astro add moonarc
pnpm astro add moonarc
npx shadcn@latest add …/r/tabs.json

Tabs with an indicator that slides to the selected one and panels that fade in.

every browserclick

A line beside an article fills as it is read, with a dot at its head.

Chrome 115 · not Firefox · Safari 26scroll

Also: view transitions · how costs are measured · browser support · accessibility policy · five-minute setup · MCP for agents · this page as Markdown