Skip to content
Brok UI

Loading…

No results

Table of Contents

Open source

In-page navigation built from a target container's headings (or explicit items), with IntersectionObserver active-highlight, smooth scroll and aria-current.

Version
v1.3.2
Stability
stable
License
MIT
Related
Sidebar
Navigation Menu
Scroll Progress

Preview

Getting started

Install the package and register the service provider. Scroll the page to watch the active section update in the navigation beside this content.

Requirements

You will need a recent PHP runtime and a compatible Laravel application before continuing with the setup steps.

Configuration

Publish the configuration file and adjust the defaults to match your environment. Each option is documented inline for clarity.

Environment variables

Sensitive values belong in your environment file rather than committed configuration so they never leak into source control.

Usage

Drop the component into any Blade view and it will smooth-scroll to each section while highlighting the heading currently in view.

Variant
previews.components.table-of-contents.default.blade.php Blade
<div class="grid gap-8 md:grid-cols-[1fr_220px]">
    <article id="toc-demo" class="space-y-4">
        <section>
            <h2 id="getting-started" class="text-xl font-semibold text-foreground">{{ __('Getting started') }}</h2>
            <p class="text-muted-foreground">{{ __('Install the package and register the service provider. Scroll the page to watch the active section update in the navigation beside this content.') }}</p>
        </section>

        <section>
            <h3 id="requirements" class="text-lg font-medium text-foreground">{{ __('Requirements') }}</h3>
            <p class="text-muted-foreground">{{ __('You will need a recent PHP runtime and a compatible Laravel application before continuing with the setup steps.') }}</p>
        </section>

        <section>
            <h2 id="configuration" class="text-xl font-semibold text-foreground">{{ __('Configuration') }}</h2>
            <p class="text-muted-foreground">{{ __('Publish the configuration file and adjust the defaults to match your environment. Each option is documented inline for clarity.') }}</p>
        </section>

        <section>
            <h3 id="environment" class="text-lg font-medium text-foreground">{{ __('Environment variables') }}</h3>
            <p class="text-muted-foreground">{{ __('Sensitive values belong in your environment file rather than committed configuration so they never leak into source control.') }}</p>
        </section>

        <section>
            <h2 id="usage" class="text-xl font-semibold text-foreground">{{ __('Usage') }}</h2>
            <p class="text-muted-foreground">{{ __('Drop the component into any Blade view and it will smooth-scroll to each section while highlighting the heading currently in view.') }}</p>
        </section>
    </article>

    <x-ui.table-of-contents target="#toc-demo" class="md:sticky md:top-4 md:self-start" />
</div>
List Current
Rail Current

Installation

terminal
php artisan ui:add table-of-contents

Note

This component ships an Alpine behavior module at resources/js/ui/table-of-contents.js. Import it once from your bundle so it registers on alpine:init:

resources/js/ui/index.js JS
import './table-of-contents.js';

Registry contract

php artisan ui:add table-of-contents writes only the files below. The CLI validates each file hash before writing and asks before it replaces a local change, unless you pass --force.

  • blade resources/views/components/ui/table-of-contents.blade.php
  • js resources/js/ui/table-of-contents.js
Registry dependencies
None — installs on its own.
Packages
composer: jml/brok:^0.2
npm: alpinejs

Use with AI

A brief for your coding agent: install command, usage, props, guidance and the rules. Copy it, or open a prompt about this component in an assistant.

table-of-contents.md
# Brok UI: Table of Contents (`table-of-contents`)

In-page navigation built from a target container's headings (or explicit items), with IntersectionObserver active-highlight, smooth scroll and aria-current.

Brok UI is a Laravel Blade component registry. Installed components are plain Blade files the app owns.

## Install

```bash
php artisan ui:add table-of-contents
```

## Usage

```blade
<div class="grid gap-8 md:grid-cols-[1fr_220px]">
    <article id="toc-demo" class="space-y-4">
        <section>
            <h2 id="getting-started" class="text-xl font-semibold text-foreground">{{ __('Getting started') }}</h2>
            <p class="text-muted-foreground">{{ __('Install the package and register the service provider. Scroll the page to watch the active section update in the navigation beside this content.') }}</p>
        </section>

        <section>
            <h3 id="requirements" class="text-lg font-medium text-foreground">{{ __('Requirements') }}</h3>
            <p class="text-muted-foreground">{{ __('You will need a recent PHP runtime and a compatible Laravel application before continuing with the setup steps.') }}</p>
        </section>

        <section>
            <h2 id="configuration" class="text-xl font-semibold text-foreground">{{ __('Configuration') }}</h2>
            <p class="text-muted-foreground">{{ __('Publish the configuration file and adjust the defaults to match your environment. Each option is documented inline for clarity.') }}</p>
        </section>

        <section>
            <h3 id="environment" class="text-lg font-medium text-foreground">{{ __('Environment variables') }}</h3>
            <p class="text-muted-foreground">{{ __('Sensitive values belong in your environment file rather than committed configuration so they never leak into source control.') }}</p>
        </section>

        <section>
            <h2 id="usage" class="text-xl font-semibold text-foreground">{{ __('Usage') }}</h2>
            <p class="text-muted-foreground">{{ __('Drop the component into any Blade view and it will smooth-scroll to each section while highlighting the heading currently in view.') }}</p>
        </section>
    </article>

    <x-ui.table-of-contents target="#toc-demo" class="md:sticky md:top-4 md:self-start" />
</div>
```

## Props

- `target` (mixed|null, default `null`) — CSS selector for the container whose headings are collected; ignored when items is supplied explicitly.
- `selector` (string, default `h2, h3`) — CSS selector used to find headings inside the target container.
- `items` (array, default `[]`) — Explicit list of { label, href } entries, used instead of deriving items from headings.
- `title` (mixed|null, default `null`) — Heading shown above the list.
- `label` (mixed|null, default `null`) — Accessible label for the navigation landmark when no visible title is shown.
- `variant` (list|rail, default `list`) — Visual layout: list (vertical) or another supported rail style.
- `root` (mixed|null, default `null`) — CSS selector for the IntersectionObserver's scrolling root, when it is not the viewport.
- `offset` (int, default `0`) — Pixel offset applied to the active-section detection, to account for a sticky header.

## Use when

- Use to orient users and help them move across pages, sections, or commands.
- Giving readers a jump list of the current page's headings, with the active section highlighted as they scroll.
- Building a long-form article, docs, or guide page that benefits from an on-this-page rail.

## Avoid when

- Do not hide primary wayfinding in novelty interactions or deep nested structures if straightforward navigation would be clearer.
- The page is short enough to scan without a jump list.
- The list should be a fixed, hand-authored set of links unrelated to the page's own headings; use navigation-menu or sidebar instead.

## Anti-patterns

- Hiding primary wayfinding in novelty interactions

## Rules

- Use the `<brok:table-of-contents>` tag (or `<x-ui.table-of-contents>`) in Blade; do not rewrite the component.
- Prefer the documented props and variants over utility-class overrides; when a utility must win, use the `!` important modifier.
- Use semantic design tokens (`bg-primary`, `text-muted-foreground`), never raw colour utilities.
- Keep the `data-slot` attributes; they are the styling and test hooks.

## Links

- Docs: https://brokui.dev/docs/components/table-of-contents
- Registry JSON (files, props, contract): https://brokui.dev/r/open/table-of-contents.json

Working in Claude Code, Cursor or Codex? Give the agent the whole registry through the MCP server or the Brok UI skill.

Examples

rail.blade.php Blade
{{-- `variant="rail"` with explicit items: the docs-page "On this page" rail.
     Server-rendered links, so deep links resolve before Alpine boots. --}}
<div class="grid gap-8 md:grid-cols-[1fr_220px]">
    <article class="space-y-4">
        <section>
            <h2 id="rail-overview" class="text-xl font-semibold text-foreground">{{ __('Overview') }}</h2>
            <p class="text-muted-foreground">{{ __('The rail marks the active section on a hairline down its start edge and keeps the title small, so it sits beside long reading content without competing with it.') }}</p>
        </section>
        <section>
            <h3 id="rail-props" class="text-lg font-medium text-foreground">{{ __('Props') }}</h3>
            <p class="text-muted-foreground">{{ __('Pass items explicitly or point the component at a container to scan its headings.') }}</p>
        </section>
        <section>
            <h2 id="rail-accessibility" class="text-xl font-semibold text-foreground">{{ __('Accessibility') }}</h2>
            <p class="text-muted-foreground">{{ __('Links carry aria-current for the active section, and choosing one moves focus to the heading so the next Tab continues into the section.') }}</p>
        </section>
    </article>

    <x-ui.table-of-contents
        variant="rail"
        :items="[
            ['id' => 'rail-overview', 'label' => __('Overview'), 'level' => 2],
            ['id' => 'rail-props', 'label' => __('Props'), 'level' => 3],
            ['id' => 'rail-accessibility', 'label' => __('Accessibility'), 'level' => 2],
        ]"
        class="md:sticky md:top-4 md:self-start"
    />
</div>
scroller.blade.php Blade
@php
    $items = [
        ['id' => 'toc-pane-introduction', 'label' => __('Introduction')],
        ['id' => 'toc-pane-features',     'label' => __('Features')],
        ['id' => 'toc-pane-conclusion',   'label' => __('Conclusion')],
    ];
@endphp

{{-- Sections inside a scrolling pane: `root="auto"` observes that pane rather
     than the viewport, and `offset` trims the active band for a fixed header. --}}
<div class="grid gap-8 md:grid-cols-[1fr_200px]">
    <div class="max-h-80 space-y-0 overflow-auto" tabindex="0" role="region" aria-label="{{ __('Sections') }}">
        <section id="toc-pane-introduction" class="min-h-64 space-y-2 py-4">
            <h2 class="text-xl font-semibold text-foreground">{{ __('Introduction') }}</h2>
            <p class="text-muted-foreground">{{ __('The list tracks which section of this pane is in view and highlights the matching link. Scroll the pane to see the active item change.') }}</p>
        </section>

        <section id="toc-pane-features" class="min-h-64 space-y-2 py-4">
            <h2 class="text-xl font-semibold text-foreground">{{ __('Features') }}</h2>
            <p class="text-muted-foreground">{{ __('An IntersectionObserver runs against the pane itself, so there is no scroll polling; the end of the pane marks the last section.') }}</p>
        </section>

        <section id="toc-pane-conclusion" class="min-h-64 space-y-2 py-4">
            <h2 class="text-xl font-semibold text-foreground">{{ __('Conclusion') }}</h2>
            <p class="text-muted-foreground">{{ __('Click any link to smooth-scroll the pane to that section.') }}</p>
        </section>
    </div>

    <x-ui.table-of-contents variant="rail" root="auto" :items="$items" :title="__('On this page')" :label="__('Page sections')" class="md:sticky md:top-4 md:self-start" />
</div>

API

manifest knowledge + registry-derived coverage

Props

Props accepted by this component: name, type, default value and description.
Prop Type Default Description
target mixed | null null CSS selector for the container whose headings are collected; ignored when items is supplied explicitly.
selector string h2, h3 CSS selector used to find headings inside the target container.
items array [] Explicit list of { label, href } entries, used instead of deriving items from headings.
title mixed | null null Heading shown above the list.
label mixed | null null Accessible label for the navigation landmark when no visible title is shown.
variant list | rail list Visual layout: list (vertical) or another supported rail style.
root mixed | null null CSS selector for the IntersectionObserver's scrolling root, when it is not the viewport.
offset int 0 Pixel offset applied to the active-section detection, to account for a sticky header.

Slots

  • default —

Data slots

Stable hooks for CSS overrides and browser tests.

table-of-contents-item table-of-contents-list table-of-contents-title {{ $slotName }}

Behavior

  • Derives entries from the target container's headings matching selector, unless an explicit items array is supplied.
  • An IntersectionObserver tracks which heading is currently in view and highlights the matching link as active while scrolling.
  • Clicking a link smooth-scrolls to its heading instead of jumping instantly.
  • Installs a JavaScript behavior module when the registry item includes resources/js/ui files.
  • Declares registry capability flags: a11y, interactive, responsive, rtl, darkMode, localized.

Guidance

Navigation and orientation

Orient users and move between destinations.

Use when

  • Use to orient users and help them move across pages, sections, or commands.
  • Giving readers a jump list of the current page's headings, with the active section highlighted as they scroll.
  • Building a long-form article, docs, or guide page that benefits from an on-this-page rail.

Avoid when

  • Do not hide primary wayfinding in novelty interactions or deep nested structures if straightforward navigation would be clearer.
  • The page is short enough to scan without a jump list.
  • The list should be a fixed, hand-authored set of links unrelated to the page's own headings; use navigation-menu or sidebar instead.

Use instead

  • Visible links and local navigation

Anti-patterns

  • Hiding primary wayfinding in novelty interactions
Anatomy
root table-of-contents-title table-of-contents-list table-of-contents-item
Theming hooks
table-of-contents-item table-of-contents-list table-of-contents-title {{ $slotName }}

Accessibility

WCAG 2.2 AA Keyboard focus-visible RTL-ready Localized labels Dark mode
Keyboard
native
Focus
managed
  • The active link is marked aria-current rather than relying on colour alone, and the root is a labelled navigation landmark.
  • Smooth scrolling honours prefers-reduced-motion for users who have motion reduction enabled.
  • Semantic HTML and a stable data-slot attribute for styling and scripting hooks.
  • Focus-visible rings use the ring token, so keyboard focus is always visible.
  • Disabled and invalid states are conveyed to assistive tech, not by color alone.
  • Targets WCAG 2.2 AA; verify contrast in light, dark, admin and customer surfaces in the preview.
  • Labels go through __() and layout uses logical properties (ms-*, text-start), so it mirrors under dir="rtl" — flip the preview to RTL to confirm.
  • Dark mode uses the same semantic tokens under the dark class; high contrast follows forced-color system tokens.

Livewire

Needs wire:key

Add a stable wire:key when Livewire can reorder this interactive component.

livewire-component.blade.php Blade
<div wire:key="table-of-contents-{{ $record->id }}">
    <div class="grid gap-8 md:grid-cols-[1fr_220px]">
        <article id="toc-demo" class="space-y-4">
            <section>
                <h2 id="getting-started" class="text-xl font-semibold text-foreground">{{ __('Getting started') }}</h2>
                <p class="text-muted-foreground">{{ __('Install the package and register the service provider. Scroll the page to watch the active section update in the navigation beside this content.') }}</p>
            </section>
    
            <section>
                <h3 id="requirements" class="text-lg font-medium text-foreground">{{ __('Requirements') }}</h3>
                <p class="text-muted-foreground">{{ __('You will need a recent PHP runtime and a compatible Laravel application before continuing with the setup steps.') }}</p>
            </section>
    
            <section>
                <h2 id="configuration" class="text-xl font-semibold text-foreground">{{ __('Configuration') }}</h2>
                <p class="text-muted-foreground">{{ __('Publish the configuration file and adjust the defaults to match your environment. Each option is documented inline for clarity.') }}</p>
            </section>
    
            <section>
                <h3 id="environment" class="text-lg font-medium text-foreground">{{ __('Environment variables') }}</h3>
                <p class="text-muted-foreground">{{ __('Sensitive values belong in your environment file rather than committed configuration so they never leak into source control.') }}</p>
            </section>
    
            <section>
                <h2 id="usage" class="text-xl font-semibold text-foreground">{{ __('Usage') }}</h2>
                <p class="text-muted-foreground">{{ __('Drop the component into any Blade view and it will smooth-scroll to each section while highlighting the heading currently in view.') }}</p>
            </section>
        </article>
    
        <x-ui.table-of-contents target="#toc-demo" class="md:sticky md:top-4 md:self-start" />
    </div>
</div>

Validation

Validation support: native. Keep the error message connected with aria-describedby.

livewire-form.blade.php Blade
<form wire:submit="save" class="space-y-2">
    <brok:table-of-contents
        wire:model="value"
        :aria-invalid="$errors->has('value') ? 'true' : 'false'"
        aria-describedby="value-error"
    />

    @error('value')
        <p id="value-error" role="alert">{{ $message }}</p>
    @enderror

    <brok:button type="submit" wire:loading.attr="disabled">
        <span wire:loading.remove>Save</span>
        <span wire:loading>Saving…</span>
    </brok:button>
</form>

Source

The exact, editable files ui:add writes into your app. Previews render this same code; there are no preview-only components.

resources/views/components/ui/table-of-contents.blade.php Blade
@props([
    'target' => null,
    'selector' => 'h2, h3',
    'items' => [],
    'title' => null,
    // Accessible name of the <nav>.
    'label' => null,
    // list (default): plain rows, the active one in foreground weight.
    // rail: a hairline down the start edge with the active row marked on it,
    // and a small uppercase title — the "On this page" rail of a docs page.
    'variant' => 'list',
    // The sections' scroll container: null observes the viewport, a selector
    // names the element, 'auto' finds the nearest scrollable ancestor of the
    // first heading (a list inside a scrolling panel).
    'root' => null,
    // Pixels trimmed from the top of the active band, for a fixed header.
    'offset' => 0,
])


@php
    // Accept a string, a backed enum or a Stringable for `variant`.
    $styles = require base_path(config('ui.component_path', 'resources/views/components/ui').'/_styles.php');
    $variant = $styles['normalizeVariant']($variant);
@endphp
{{--
    Table of Contents (spec §16B).
    A navigation list of in-page headings. Headings are either scanned from the
    `target` container at runtime or passed explicitly via `:items`. Explicit
    items are rendered on the server, so the links exist (and deep links resolve)
    before Alpine boots; scanned items render client-side. An item may carry a
    `meta` string (a date, a count) shown after its label. The active section is
    tracked with an IntersectionObserver (the last section wins once the page is
    scrolled to its end) and reflected with `aria-current="location"`; links
    smooth-scroll (motion-reduce safe), update the URL hash and hand focus to
    the heading so the next Tab continues into the section.
--}}
@php
    $variant = in_array($variant, ['list', 'rail'], true) ? $variant : 'list';
    $rail = $variant === 'rail';
    $indent = $rail
        ? static fn (int $level): string => $level <= 2 ? 'ps-3' : ($level === 3 ? 'ps-6' : 'ps-9')
        : static fn (int $level): string => $level <= 2 ? 'ps-0' : ($level === 3 ? 'ps-4' : 'ps-8');
    $indentBinding = $rail
        ? "{ 'ps-3': item.level <= 2, 'ps-6': item.level === 3, 'ps-9': item.level >= 4 }"
        : "{ 'ps-0': item.level <= 2, 'ps-4': item.level === 3, 'ps-8': item.level >= 4 }";
    $ring = 'focus-visible:outline-none focus-visible:ring-[length:var(--ring-width)] focus-visible:ring-ring focus-visible:ring-offset-[length:var(--ring-offset-width)] focus-visible:ring-offset-background';
    $linkClass = $rail
        ? '-ms-px flex items-baseline justify-between gap-2 border-s border-transparent py-1 pe-2 text-sm transition-colors hover:border-foreground/40 hover:text-foreground motion-reduce:transition-none '.$ring
        : 'flex items-baseline justify-between gap-2 py-1 text-sm transition-colors hover:text-foreground motion-reduce:transition-none '.$ring;
    $activeClass = $rail ? 'border-foreground font-medium text-foreground' : 'font-medium text-foreground';
    $idleClass = $rail ? 'border-transparent text-muted-foreground' : 'text-muted-foreground';
    $titleClass = $rail
        ? 'mb-2 px-3 text-2xs font-semibold uppercase tracking-wider text-muted-foreground'
        : 'mb-2 text-sm font-medium text-foreground';
    $listClass = $rail ? 'space-y-0.5 border-s border-border' : 'space-y-1';
    // scroll-spy renders this component under its own name; the browser
    // keeps only the first of two identical attributes, so read the override
    // out of the bag and print exactly one.
    $slotName = (string) ($attributes->get('data-slot') ?: 'table-of-contents');
@endphp
<nav
    data-slot="{{ $slotName }}"
    data-variant="{{ $variant }}"
    aria-label="{{ $label ?? __('Table of contents') }}"
    x-data="uiTableOfContents({ target: @js($target), selector: @js($selector), items: @js($items), root: @js($root), offset: @js((int) $offset) })"
    {{ $attributes->except('data-slot')->merge(['class' => 'text-sm text-muted-foreground']) }}
>
    @if ($title !== false)
        <p data-slot="table-of-contents-title" class="{{ $titleClass }}">
            {{ $title ?? __('On this page') }}
        </p>
    @endif

    <ol data-slot="table-of-contents-list" class="{{ $listClass }}">
        @if ($items !== [])
            @foreach ($items as $item)
                <li data-slot="table-of-contents-item" @if (! $rail) class="{{ $indent((int) ($item['level'] ?? 2)) }}" @endif>
                    <a
                        href="#{{ $item['id'] }}"
                        @click.prevent="scrollTo(@js($item['id']))"
                        :aria-current="active === @js($item['id']) ? 'location' : null"
                        :class="active === @js($item['id']) ? @js($activeClass) : @js($idleClass)"
                        class="{{ $linkClass }} {{ $rail ? $indent((int) ($item['level'] ?? 2)) : '' }}"
                    >
                        <span class="min-w-0 truncate">{{ $item['label'] }}</span>
                        @if (! empty($item['meta']))
                            <span class="shrink-0 font-mono text-xs">{{ $item['meta'] }}</span>
                        @endif
                    </a>
                </li>
            @endforeach
        @else
            <template x-for="item in items" :key="item.id">
                <li data-slot="table-of-contents-item" @if (! $rail) :class="{{ $indentBinding }}" @endif>
                    <a
                        :href="'#' + item.id"
                        @click.prevent="scrollTo(item.id)"
                        :aria-current="active === item.id ? 'location' : null"
                        :class="[active === item.id ? @js($activeClass) : @js($idleClass), {{ $rail ? $indentBinding : '{}' }}]"
                        class="{{ $linkClass }}"
                    >
                        <span class="min-w-0 truncate" x-text="item.label"></span>
                        <span class="shrink-0 font-mono text-xs" x-show="item.meta" x-text="item.meta"></span>
                    </a>
                </li>
            </template>
        @endif
    </ol>
</nav>
resources/js/ui/table-of-contents.js JS
/**
 * Table of Contents behavior (spec §16B).
 *
 * Builds a navigation list from the headings inside a `target` container (or
 * the whole document) matching `selector`, unless an explicit `items` array is
 * supplied. Each scanned heading is given a stable id (slugified from its text)
 * when it lacks one. An IntersectionObserver highlights the topmost heading in
 * view as `active`. The observer runs against the viewport by default; `root`
 * names the sections' scroll container instead (a selector, or 'auto' for the
 * nearest scrollable ancestor of the first heading) so a list inside a
 * scrolling panel tracks that panel, and `offset` (px) trims the top of the
 * active band for a fixed header. The end of the scroll area marks the last
 * heading (a short last section never reaches the band). `scrollTo`
 * smooth-scrolls to a section, honoring prefers-reduced-motion. Listeners are
 * torn down on destroy.
 */
document.addEventListener('alpine:init', () => {
    window.Alpine.data('uiTableOfContents', (config = {}) => ({
        items: config.items ?? [],
        active: null,
        target: config.target ?? null,
        selector: config.selector ?? 'h2, h3',
        root: config.root ?? null,
        offset: config.offset ?? 0,
        _observer: null,
        _headings: [],
        _scroller: null,
        _onScroll: null,

        init() {
            const root = this.target ? document.querySelector(this.target) : document;

            if (!root) {
                return;
            }

            // Seed items from the DOM only when none were provided explicitly.
            if (!this.items.length) {
                const headings = Array.from(root.querySelectorAll(this.selector));

                this.items = headings.map((el) => {
                    if (!el.id) {
                        // Disambiguate duplicate headings: append -2, -3, … so
                        // getElementById later resolves the right one, not the first.
                        const base = this.slugify(el.textContent || '');
                        let id = base;
                        let n = 2;
                        while (document.getElementById(id)) {
                            id = `${base}-${n++}`;
                        }
                        el.id = id;
                    }

                    return {
                        id: el.id,
                        label: (el.textContent || '').trim(),
                        level: Number(el.tagName.replace(/\D/g, '')) || 2,
                    };
                });
            }

            // Resolve the heading elements we will observe (works for both
            // scanned and explicit items, as long as the ids exist in the DOM).
            this._headings = this.items
                .map((item) => document.getElementById(item.id))
                .filter(Boolean);

            if (!this._headings.length) {
                return;
            }

            const rootEl = this.resolveRoot(this._headings[0]);

            this._observer = new IntersectionObserver(
                (entries) => {
                    const visible = entries
                        .filter((entry) => entry.isIntersecting)
                        .sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top);

                    if (visible.length) {
                        this.active = visible[0].target.id;
                    }
                },
                { root: rootEl, rootMargin: `-${this.offset}px 0px -70% 0px`, threshold: 0 }
            );

            this._headings.forEach((el) => this._observer.observe(el));

            // A short last section never reaches the observer's top band, so the
            // end of the scroll area marks the last heading instead.
            this._scroller = rootEl ?? window;
            this._onScroll = () => {
                const el = rootEl ?? document.documentElement;
                const top = rootEl ? rootEl.scrollTop : window.scrollY;
                const height = rootEl ? rootEl.clientHeight : window.innerHeight;
                if (top + height >= el.scrollHeight - 2) {
                    this.active = this._headings[this._headings.length - 1].id;
                }
            };
            this._scroller.addEventListener('scroll', this._onScroll, { passive: true });

            // Default to the hash, else the first heading, until the observer reports.
            const hash = decodeURIComponent(location.hash.slice(1));
            if (this.active === null && this.items.length) {
                this.active = this.items.some((item) => item.id === hash) ? hash : this.items[0].id;
            }
        },

        /**
         * The observer's root: null for the viewport (default), the element a
         * selector names, or — for 'auto' — the nearest ancestor of the first
         * heading that scrolls vertically. Judged by overflow alone: the
         * component may initialise while its stage is still hidden (a docs
         * tab, a collapsed panel), when scrollHeight and clientHeight are 0.
         */
        resolveRoot(first) {
            if (!this.root) {
                return null;
            }
            if (this.root !== 'auto') {
                return document.querySelector(this.root);
            }
            for (let node = first?.parentElement; node && node !== document.body; node = node.parentElement) {
                const { overflowY } = getComputedStyle(node);
                if (overflowY === 'auto' || overflowY === 'scroll') {
                    return node;
                }
            }
            return null;
        },

        scrollTo(id) {
            const el = document.getElementById(id);

            if (!el) {
                return;
            }

            // A CSS `scroll-behavior: auto` under reduced motion does not override
            // a JS scrollIntoView({ behavior: 'smooth' }) argument.
            const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
            el.scrollIntoView({ behavior: reduce ? 'auto' : 'smooth', block: 'start' });
            this.active = id;
            history.replaceState(null, '', `#${id}`);
            // The prevented click cancelled the browser's focus move too: put focus
            // on the heading so the next Tab continues into the section.
            if (!el.hasAttribute('tabindex')) el.setAttribute('tabindex', '-1');
            el.focus({ preventScroll: true });
        },

        slugify(text) {
            return text
                .toString()
                .trim()
                .toLowerCase()
                .replace(/[^\w\s-]/g, '')
                .replace(/[\s_]+/g, '-')
                .replace(/-+/g, '-')
                .replace(/^-+|-+$/g, '');
        },

        destroy() {
            if (this._observer) {
                this._observer.disconnect();
                this._observer = null;
            }
            if (this._scroller && this._onScroll) {
                this._scroller.removeEventListener('scroll', this._onScroll);
                this._onScroll = null;
                this._scroller = null;
            }
        },
    }));
});

Ownership & lifecycle

Owner, release state, review evidence and adoption for this item.
Owner
Platform UI (@JoshJML)
Current version
1.3.2
Status
Stable
License
open
Accessibility reviewed
No review date recorded
Last breaking change
No date recorded
Deprecation
Not deprecated
Contract
v6
Foundation
≥ 1.0.0