PXD

Toc

Renders an outline of the headings in your page, tracks which one the reader is currently on, and scrolls to a heading on activation.

Default

Point selector at the container that holds your content. The > combinator matters: a heading nested one level deeper, inside a demo or a callout, is not part of the outline and a descendant selector would list it anyway.

Install

Filler so that the headings can travel across the probe line.

Configure

A nested heading is indented one step further than the shallowest heading in the list.

Usage

Scrolling back up hands the highlight to the previous heading as soon as it crosses the probe line.

Events

The last entry stays reachable even when its section is too short to scroll up to the probe line.

<script setup>
import { shallowRef } from 'vue'

const viewport = shallowRef(null)
</script>

<template>
  <div class="flex items-start max-sm:flex-col gap-6">
    <PToc selector=".demo-basic > :is(h2, h3)[id]" :scroll-target="viewport" class="w-40 shrink-0" />

    <div ref="viewport" class="max-h-40 flex-1 overflow-y-auto rounded-lg border p-4">
      <div class="demo-basic">
        <h2 id="basic-install">Install</h2>
        <p class="text-foreground-secondary">
          Filler so that the headings can travel across the probe line.
        </p>

        <h3 id="basic-configure">Configure</h3>
        <p class="text-foreground-secondary">
          A nested heading is indented one step further than the shallowest heading in the list.
        </p>

        <h2 id="basic-usage">Usage</h2>
        <p class="text-foreground-secondary">
          Scrolling back up hands the highlight to the previous heading as soon as it crosses the probe
          line.
        </p>

        <h3 id="basic-events">Events</h3>
        <p class="text-foreground-secondary">
          The last entry stays reachable even when its section is too short to scroll up to the probe line.
        </p>
      </div>
    </div>
  </div>
</template>

Offset

offset is the single source of truth for both directions: the component scrolls the heading to offset pixels below the top of the scroll container, and uses the same line to decide which entry is active. Set it to the height of a sticky header so the heading is never hidden underneath it.

Section A

Filler so that the headings can travel across the probe line.

Section B

Filler so that the headings can travel across the probe line.

Section C

Filler so that the headings can travel across the probe line.

<script setup>
import { shallowRef } from 'vue'

const viewport = shallowRef(null)
</script>

<template>
  <div class="flex items-start max-sm:flex-col gap-6">
    <PToc
      selector=".demo-offset > h2[id]"
      :scroll-target="viewport"
      :offset="24"
      label="Sections"
      class="w-40 shrink-0"
    />

    <div ref="viewport" class="max-h-40 flex-1 overflow-y-auto rounded-lg border p-4">
      <div class="demo-offset">
        <h2 id="offset-a">Section A</h2>
        <p class="text-foreground-secondary">Filler so that the headings can travel across the probe line.</p>

        <h2 id="offset-b">Section B</h2>
        <p class="text-foreground-secondary">Filler so that the headings can travel across the probe line.</p>

        <h2 id="offset-c">Section C</h2>
        <p class="text-foreground-secondary">Filler so that the headings can travel across the probe line.</p>
      </div>
    </div>
  </div>
</template>

Custom Item

Use the item slot to render an entry yourself. The slot receives the entry, its position, its depth relative to the shallowest heading, whether it is the active one, and a select handler. A slot replaces the default anchor entirely, so the indent is yours to set and select is what keeps the offset-aware scroll.

Alpha

Filler so that the headings can travel across the probe line.

Beta

Filler so that the headings can travel across the probe line.

<script setup>
import { shallowRef } from 'vue'

const viewport = shallowRef(null)
</script>

<template>
  <div class="flex items-start max-sm:flex-col gap-6">
    <PToc selector=".demo-slot > :is(h2, h3)[id]" :scroll-target="viewport" class="w-40 shrink-0">
      <template #item="{ item, depth, active, select }">
        <a
          :href="`#${item.id}`"
          class="block truncate rounded-md border-l-2 py-1.5 pe-2 text-start no-underline"
          :class="[
            depth ? 'pl-6' : 'pl-3',
            active
              ? 'border-primary bg-gray-alpha-100 font-medium text-primary'
              : 'border-transparent text-foreground-secondary hover:bg-gray-alpha-100',
          ]"
          :aria-current="active ? 'location' : undefined"
          @click="select"
        >
          {{ item.label }}
        </a>
      </template>
    </PToc>

    <div ref="viewport" class="max-h-40 flex-1 overflow-y-auto rounded-lg border p-4">
      <div class="demo-slot">
        <h2 id="slot-alpha">Alpha</h2>
        <p class="text-foreground-secondary">Filler so that the headings can travel across the probe line.</p>

        <h3 id="slot-beta">Beta</h3>
        <p class="text-foreground-secondary">Filler so that the headings can travel across the probe line.</p>
      </div>
    </div>
  </div>
</template>

Minimal Rail

A slot replaces the whole entry, so it also owns the indent. Render nothing but a small bar, and reveal the label on hover or keyboard focus. select keeps the offset-aware scroll that the default entry has — call it from your own click handler.

Getting started

Filler so that the headings can travel across the probe line.

Installation

Filler so that the headings can travel across the probe line.

Configuration

Filler so that the headings can travel across the probe line.

Deployment

Filler so that the headings can travel across the probe line.

<script setup>
import { shallowRef } from 'vue'

const viewport = shallowRef(null)
</script>

<template>
  <div class="flex items-start max-sm:flex-col gap-6">
    <PToc selector=".demo-rail > :is(h2, h3)[id]" :scroll-target="viewport" class="w-6 shrink-0">
      <template #item="{ item, active, select }">
        <a
          :href="`#${item.id}`"
          :aria-label="item.label"
          :aria-current="active ? 'location' : undefined"
          class="group relative flex h-5 items-center rounded-sm p-1 no-underline self-focus-ring outline-none"
          @click="select"
        >
          <span
            class="h-1 rounded-full motion-safe:transition-all motion-safe:duration-150"
            :class="active ? 'w-5 bg-primary' : 'w-2.5 bg-gray-400 group-hover:w-5 group-hover:bg-gray-700'"
          />

          <span
            class="pointer-events-none absolute start-7 z-10 rounded-md bg-gray-900 px-2 py-1 text-xs whitespace-nowrap text-white opacity-0 motion-safe:transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100"
          >
            {{ item.label }}
          </span>
        </a>
      </template>
    </PToc>

    <div ref="viewport" class="max-h-40 flex-1 overflow-y-auto rounded-lg border p-4">
      <div class="demo-rail">
        <h2 id="rail-start">Getting started</h2>
        <p class="text-foreground-secondary">Filler so that the headings can travel across the probe line.</p>

        <h3 id="rail-install">Installation</h3>
        <p class="text-foreground-secondary">Filler so that the headings can travel across the probe line.</p>

        <h3 id="rail-config">Configuration</h3>
        <p class="text-foreground-secondary">Filler so that the headings can travel across the probe line.</p>

        <h2 id="rail-deploy">Deployment</h2>
        <p class="text-foreground-secondary">Filler so that the headings can travel across the probe line.</p>
      </div>
    </div>
  </div>
</template>

Props

NameTypeDefaultDescription
selectorstring-Headings that make up the outline, as a CSS selector matched inside scroll-target (or the document). Re-read when it changes, and when that subtree mutates.
scroll-targetHTMLElement | nullnullScrollable container holding the headings; the outline is read and watched inside it. Leave empty to read the document.
offsetnumber0Pixels kept above the heading when scrolling to it, and the probe line used to detect the active entry.
scroll-behavior'auto' | 'instant' | 'smooth''auto'Scroll animation when an entry is activated; auto follows the system motion preference.
scroll-active-into-viewbooleantrueScroll the active entry back into view when it leaves the list.

Events

NameTypeDescription
item-click(item: TocItem, event: MouseEvent) => voidEmitted when the user activates an entry.
active-change(item: TocItem | null) => voidEmitted when the active heading changes.
interface TocItem {
  /** `id` of the heading element this entry scrolls to. */
  id: string
  /** Text content of the heading, with a leading `#` permalink stripped. */
  label: string
  /** Heading level parsed from the tag name, 1-6. */
  level: number
}

Slots

NameDescription
itemEntry renderer, replacing the default anchor and its indent. Slot props: item, index, depth, active, select. Bind select to your own click handler to keep the offset-aware scroll.

Methods

NameTypeDescription
activeIdstring | nullThe id of the entry that is currently highlighted.
itemsTocItem[]The outline the component read from scroll-target (or the document).
scrollTo(id: string) => booleanScroll to the entry with the given id, returning whether it was found.
update() => voidRecompute the highlighted entry from the current layout.

Source

Source