---
title: On-Page TOC
description: "Layout wiring for an in-page table of contents: a right-edge sticky spine that reads the page headings, highlights the active one, and follows the reader."
visibility: guest
draft: false
related:
  - /components/reading
  - /blueprints/recipes/long-form-article
  - /blueprints/recipes/marketing-landing-toc
  - /blueprints/recipes/documentation-reader
---

# On-Page TOC

The canonical wiring for an in-page TOC alongside long-form content — articles, docs pages, change logs, anything with five or more headings. WireKit ships two primitives that map onto the two common shapes:

- **Sidebar TOC** (`<x-wirekit::reading-spine>`) — right-edge sticky vertical sidebar, hover-to-expand, narrow at rest. Matches Stripe, Linear, Vercel, shadcn docs. **Recommended for docs / articles.**
- **Horizontal strip TOC** (`<x-wirekit::reading-toc>`) — sticky strip across the top of the viewport. Right shape for flat marketing pages with 3-4 anchored sections. See the [marketing-landing-toc](/blueprints/recipes/marketing-landing-toc) recipe.

This recipe covers the sidebar pattern.

## The canonical wiring

Three primitives compose:

1. **`<x-wirekit::reading-progress>`** — the top-edge progress bar (full-page, not scoped to article). Optional; drop if your layout doesn't want one.
2. **`<x-wirekit::reading-spine>`** — the right-edge sidebar TOC. Reads `<main>` headings at the H2 / H3 levels. `expand="hover"` keeps the spine narrow at rest; expands to show labels on hover.
3. **`<main>` content** — the spine reads headings from this element. Use `target="main"` (the prop default `null` resolves to `'main, article'` first-match wins).

:::preview{title="On-page TOC with sticky sidebar spine", frame="iframe", height="640px", flush="true"}
<x-wirekit::container max="2xl" padding="lg">
<x-wirekit::reading-shell :previewMode="true" bookmarkKey="on-page-toc-recipe-demo" style="--reading-spine-width-expanded: 11rem;">
    <x-wirekit::prose density="compact">
        <article class="wk-reading-content">
            <h2 id="otoc-intro">Introduction</h2>
            <p>The right-edge spine builds its link list from the headings inside this <code>article</code>. Scroll down to watch the active section update in real time. The progress bar at the very top tracks how far you've read through the entire page.</p>
            <p>This recipe is the WireKit equivalent of the Stripe / Linear / Vercel docs sidebar. The shape is opinionated: collapsed at rest (visible as a narrow dot column), expanded on hover. Mobile drops the spine entirely — narrow viewports cannot host a sidebar without overflowing the main content column.</p>

            <h2 id="otoc-setup">Setup</h2>
            <p>The simplest wiring is three primitives composed together: a <code>&lt;x-wirekit::reading-progress&gt;</code> at the top, a <code>&lt;x-wirekit::reading-spine&gt;</code> on the side, and a regular <code>&lt;main&gt;</code> wrapping your article body. The spine reads its data source from <code>target="main"</code>; the article inside picks up the same selector via the family default.</p>

            <h3 id="otoc-prerequisites">Prerequisites</h3>
            <p>Heading IDs must be present for the spine's scroll-to-anchor links to land cleanly. Most Markdown renderers auto-id headings; if you're hand-writing Blade, add <code>id="section-name"</code> explicitly on every <code>&lt;h2&gt;</code> / <code>&lt;h3&gt;</code> you want surfaced.</p>

            <h3 id="otoc-css-variables">CSS variables that matter</h3>
            <p>The spine reads <code>--reading-spine-width-expanded</code> (default <code>14rem</code>) for the hover-expanded width. Override it in the wrapping element's <code>style</code>; it cascades to the spine via CSS-variable inheritance.</p>

            <h2 id="otoc-deep-customization">Deep customization</h2>
            <p>The spine accepts <code>target="…"</code> for a non-default selector, <code>levels="2,3,4"</code> for additional heading depth, <code>expand="always"</code> to keep the spine permanently expanded (helpful on dedicated docs layouts), and <code>hideBelow="md"</code> to override the default mobile-hide breakpoint.</p>

            <h3 id="otoc-active-link-highlight">Active-link highlight</h3>
            <p>The spine wires an <code>IntersectionObserver</code> that updates the active link whenever a heading enters the viewport's top third. No developer wiring needed; the highlight CSS is built into the primitive.</p>

            <h2 id="otoc-troubleshooting">Troubleshooting</h2>
            <p>If the spine renders empty: check the developer console for the <code>[wirekit] reading-spine: target "X" matched no element</code> warning. The default <code>'main, article'</code> selector is silent on miss; an explicit <code>target="#my-section"</code> that doesn't resolve emits the warning.</p>
        </article>
    </x-wirekit::prose>
</x-wirekit::reading-shell>
</x-wirekit::container>
:::

:::source{language="blade"}
{{-- # 1. Wrap the article body in a <main> so reading-spine can find headings --}}
{{-- # 2. Drop <x-wirekit::reading-spine> as a sibling (NOT a descendant) --}}
{{-- # 3. The spine auto-builds from the H2 / H3 elements inside <main, article> --}}
<x-wirekit::reading-progress />

<x-wirekit::reading-spine
    target="main"
    levels="2,3"
    expand="hover"
/>

<main>
    <article>
        <h2 id="intro">Introduction</h2>
        <p>…</p>

        <h2 id="setup">Setup</h2>
        <h3 id="prerequisites">Prerequisites</h3>
        <p>…</p>

        <h2 id="usage">Usage</h2>
        <p>…</p>
    </article>
</main>
:::

## CSS variables

The reading-spine reads these CSS variables; override in the wrapping element's `style="…"` or in `:root {}` for a global preset:

| Variable | Default | Purpose |
|---|---|---|
| `--reading-spine-width-collapsed` | `1.5rem` | Narrow-at-rest width (visible as a tick column) |
| `--reading-spine-width-expanded` | `16rem` | Expanded width on hover / `expand="always"` |
| `--reading-spine-offset-top` | `1rem` | `boundary="container"` only — pins the spine below the scroll-container top. Not used in this viewport-pinned recipe (the spine is vertically centered). |
| `--reading-spine-padding-y` | `0.875rem` | Vertical padding inside the spine column |
| `--reading-spine-color-active` | `var(--color-wk-text)` | Active-link color |

For the full token reference and theming guide, see [`/theming#reading-spine`](/theming).

## Integration with a sticky header

If your layout has a sticky header (e.g. `<x-wirekit::brand-bar sticky>`), pass the bar's height to the spine's `:offset` prop. The viewport-pinned spine is vertically centered, so it never sits behind the bar — but the IntersectionObserver fires when a heading crosses the *viewport* top (behind the bar), so without the offset the active link activates late. `:offset` moves that detection line down by the bar's height.

:::preview{title="On-page TOC under a sticky brand-bar", frame="iframe", height="560px", flush="true"}
<x-wirekit::brand-bar sticky>
    <x-slot:brand>
        <x-wirekit::brand name="Docs" />
    </x-slot:brand>
</x-wirekit::brand-bar>
<x-wirekit::container max="2xl" padding="lg">
    <x-wirekit::reading-shell :previewMode="true" bookmarkKey="on-page-toc-sticky-demo" style="--reading-spine-width-expanded: 11rem;">
        <x-wirekit::prose density="compact">
            <article class="wk-reading-content">
            <h2 id="otoc-st-intro">Introduction</h2>
            <p>The brand-bar above stays pinned to the viewport top as the reader scrolls. The spine itself is vertically centered and pinned to the viewport, so it never sits behind the bar — the only thing the bar affects is the active-link timing.</p>
            <p>The IntersectionObserver fires when a heading crosses the top of the viewport, which is now behind the bar. Without an offset, the active-link highlight activates late: a section highlights only after it has already scrolled up under the bar.</p>
            <h2 id="otoc-st-setup">Setup</h2>
            <p>Pass the bar's height to the spine's <code>:offset</code> prop. That moves the active-link detection line down by the bar's height, so the highlighted section matches what's actually visible below the bar. The <code>:offset</code> prop goes on <code>&lt;x-wirekit::reading-spine&gt;</code> directly — see the code below.</p>
            <p>The bar's height is layout-specific. Common values: <code>3.5rem</code> for a compact bar, <code>4rem</code> for the default WireKit brand-bar, <code>5rem</code> for a tall logo-plus-tagline bar. Match the value you actually render; don't guess.</p>
            <h2 id="otoc-st-coordination">One knob, one value</h2>
            <p>Only the <code>:offset</code> prop needs the bar's height. The spine's rendered position is handled for you (centered, pinned to the viewport), so there is no second value to keep in sync — compute the bar height once and pass it to <code>:offset</code>.</p>
            <p>A common foot-gun: forgetting <code>:offset</code> entirely. The spine still renders correctly (it is centered, clear of the bar) but the active-link highlight is off by the bar's height — the reader scrolls past a heading and the spine doesn't react until that heading has scrolled out of view.</p>
            <h2 id="otoc-st-alternatives">Alternative layouts</h2>
            <p>If the brand-bar is non-sticky, drop the offset entirely — the active-link line reads from the natural viewport top. For sticky bars that include a search input or other interactive chrome, give a little more than the bar's exact height — e.g. bar 4rem, <code>:offset="'4.5rem'"</code> — so the active-section heading isn't flush against the bar's bottom edge.</p>
            <h2 id="otoc-st-notes">Notes</h2>
            <p>The rendered position (viewport-pinned, centered) and the active-link detection line (the <code>:offset</code> prop) are independent. In this viewport-pinned recipe the layout never needs a CSS-variable tweak; the container-scoped <code>boundary="container"</code> mode uses <code>--reading-spine-offset-top</code> instead — see the reading component reference.</p>
        </article>
    </x-wirekit::prose>
    </x-wirekit::reading-shell>
</x-wirekit::container>
:::

:::source{language="blade"}
{{-- # 1. Brand bar sticks to the viewport top; note its height (5rem here) --}}
{{-- # 2. Pass that same height to the spine's :offset prop --}}
{{-- # 3. :offset moves the IntersectionObserver active-link line below the bar --}}
<x-wirekit::brand-bar sticky>
    <x-slot:brand>
        <x-wirekit::brand name="Docs" />
    </x-slot:brand>
</x-wirekit::brand-bar>

{{-- # 4. The spine is viewport-pinned + vertically centered — it never sits --}}
{{--      behind the bar, so only the active-link math needs the offset. --}}
<x-wirekit::reading-spine
    target="main"
    levels="2,3"
    :offset="'5rem'"
    expand="hover"
/>

<main>
    <article>…</article>
</main>
:::

## Composing with a breadcrumb

If your layout also carries a `<x-wirekit::breadcrumb>`, the breadcrumb's active node and the spine's active link should reference the same logical position — both reflect "where the reader is". The breadcrumb is page-level (which page in the docs tree?); the spine is in-page (which section on the current page?).

The breadcrumb stays static for the whole page; the spine's active link updates as the reader scrolls.

:::preview{title="On-page TOC with breadcrumb above the article", frame="iframe", height="560px", flush="true"}
<x-wirekit::brand-bar sticky>
    <x-slot:brand>
        <x-wirekit::brand name="Docs" />
    </x-slot:brand>
</x-wirekit::brand-bar>
<x-wirekit::container max="2xl" padding="lg">
    <x-wirekit::breadcrumb schema="false" :items="[
        ['label' => 'Docs', 'href' => '#'],
        ['label' => 'Recipes', 'href' => '#'],
        ['label' => 'On-Page TOC'],
    ]" />
</x-wirekit::container>
    <x-wirekit::reading-shell :previewMode="true" bookmarkKey="on-page-toc-breadcrumb-demo" style="--reading-spine-width-expanded: 11rem;">
        <x-wirekit::prose density="compact">
            <article>
                <h2 id="otoc-bc-intro">Introduction</h2>
                <p>The breadcrumb at the top names the page's position in the docs tree. The spine on the right names the reader's position within this page. Both stay visible as the reader scrolls.</p>
                <p>Two locator widgets, two reading goals: the breadcrumb answers "which page am I on?"; the spine answers "where in this page am I?". Showing both is the canonical documentation-page shape — Stripe, Linear, Vercel, shadcn docs all combine them.</p>
                <h2 id="otoc-bc-layering">Layering</h2>
                <p>Render the breadcrumb above the reading-shell so it scrolls with the article body rather than competing with the brand-bar's sticky layer. The brand-bar pins; the breadcrumb scrolls; the article body fills the remaining space and is itself scroll-tracked by the spine.</p>
                <p>If you absolutely want the breadcrumb to stick too, give it its own sticky offset that respects the brand-bar's height. But beware: too many sticky layers stack vertical-space cost; readers on narrow viewports lose article-body height that's hard to recover.</p>
                <h2 id="otoc-bc-coordination">Coordinating active state</h2>
                <p>The breadcrumb's active node and the spine's active link should reference the same logical position. The breadcrumb's last item is the current page; the spine's active link is the current section within that page. Together they form a fully-located "you are here" pair.</p>
                <p>If your routing carries deep-link anchors (e.g. <code>/docs/recipes/on-page-toc#setup</code>), the spine can update the breadcrumb's last item to include the section name on scroll. That's an optional polish step — most docs sites keep the breadcrumb static and let the spine carry the per-section signal alone.</p>
                <h2 id="otoc-bc-mobile">Mobile</h2>
                <p>Drop the spine on mobile (default <code>hideBelow="md"</code>); keep the breadcrumb. The breadcrumb is single-row and survives narrow viewports gracefully; the sidebar spine would either obscure the article body or overflow.</p>
                <p>For long breadcrumb trails, consider truncating intermediate nodes (Docs › … › On-Page TOC) on narrow viewports. WireKit's breadcrumb component supports this via its built-in collapse behavior.</p>
                <h2 id="otoc-bc-notes">Notes</h2>
                <p>Both locators are independent components — they don't communicate. Each owns its own state. That makes the composition robust: swap either one out without touching the other.</p>
                <p>For analytics, listen on both the spine's <code>section-changed</code> event and the breadcrumb's own click event. Together they paint a complete picture of reader navigation within and across pages.</p>
            </article>
        </x-wirekit::prose>
    </x-wirekit::reading-shell>
:::

:::source{language="blade"}
<x-wirekit::brand-bar sticky />
<x-wirekit::breadcrumb :items="$breadcrumbs" />

<x-wirekit::reading-spine target="main" levels="2,3" expand="hover" />

<main>
    <article>…</article>
</main>

<x-wirekit::footer />
:::

## Tips

- **Use `expand="always"`** on dedicated docs layouts (where readers expect the TOC) instead of the default `expand="hover"` — saves a hover-discovery moment.
- **Mobile users see no spine** by default. The narrow-viewport experience drops the TOC; readers scroll the article without it. The progress bar at the top still works on mobile.
- **For flat content** (3-4 sections, all `<h2>`), prefer the horizontal `<x-wirekit::reading-toc>` strip — see the [marketing-landing-toc](/blueprints/recipes/marketing-landing-toc) recipe.
- **For very long articles** (50+ headings), the spine works but readers benefit from the [`<x-wirekit::reading-minimap>`](/components/reading) variant — a reflection of the article's structure as a vertical ruler.

## See also

- [`<x-wirekit::reading-spine>`](/components/reading) — the sidebar primitive in detail.
- [`<x-wirekit::reading-progress>`](/components/reading) — the top-edge progress bar.
- [`<x-wirekit::reading-shell>`](/components/reading) — the one-tag composition wrapper that bundles progress + spine + bookmark with sensible defaults.
- [marketing-landing-toc recipe](/blueprints/recipes/marketing-landing-toc) — the horizontal strip variant for flat marketing pages.
- [long-form-article recipe](/blueprints/recipes/long-form-article) — the full long-form article composition (max-width container + reading-meta + prose body).
- [documentation-reader recipe](/blueprints/recipes/documentation-reader) — the docs-style composition (reading-progress + app-shell + reading-spine + article).
