Table of Contents
The TOC system uses three CORE-OWNED components that share a single link-list primitive and provide two purpose-specific wrappers. (D-D3-08)
| Component | File | Purpose |
|---|---|---|
TocList | src/core/components/ui/TocList.astro | Shared link-list primitive — no container, no JS |
TocAside | src/core/components/ui/TocAside.astro | Sticky sidebar card with scroll and active links |
TocInline | src/core/components/ui/TocInline.astro | Collapsible in-page display with vanilla JS toggle |
Route templates consume TocAside or TocInline directly — never TocList on its own.
Who this is for
- Implementors wiring TOC into route templates
- Developers customizing TOC behavior via frontmatter
- AI agents composing detail routes with TOC support
Architecture
TocList (shared primitive)
├── TocAside (sidebar wrapper)
│ ├── Sticky positioning
│ ├── Internal scroll container
│ └── IntersectionObserver active-link tracking
└── TocInline (inline wrapper)
├── Collapsible vanilla JS toggle
├── Soft-background container
└── No scroll, no sticky, no active links
Both wrappers delegate heading filtering and link rendering to TocList. They add their own container styling, positioning, and interactivity.
Current consumers
| Route | Component | Notes |
|---|---|---|
src/pages/articles/[...slug].astro | TocAside | Sidebar TOC with active-link tracking |
src/pages/theme-docs/[...slug].astro | TocInline | In-page collapsible TOC in the main content header |
src/pages/theme-docs/showcase/*.astro | TocAside | Sidebar TOC for showcase pages |
TocList (shared primitive)
Filters headings by depth range and renders a <nav><ol> of anchor links. No container styling, no scroll, no active-link tracking.
Props
| Prop | Type | Default | Notes |
|---|---|---|---|
headings | TocHeading[] | [] | Array of heading objects from render() |
label | string | "On this page" | Accessible label for the <nav> element |
allowEmpty | boolean | false | Render the shell even with no items |
startLevel | number | 2 | Minimum heading depth to include (1–6) |
endLevel | number | 3 | Maximum heading depth to include (1–6) |
class | string | — | Additional CSS classes |
TocHeading interface
Exported from TocList.astro and imported by both wrappers:
interface TocHeading {
depth: number; // 1–6 (h1–h6)
slug: string; // Anchor ID for the heading
text: string; // Plain text content
}
CSS class names
| Class | Element | Purpose |
|---|---|---|
toc-list | <nav> | Root element |
toc-list__items | <ol> | Ordered list container |
toc-list__item | <li> | List item |
toc-list__item--nested | <li> | Indented item (depth > minLevel) |
toc-list__link | <a> | Anchor link |
toc-list__link--active | <a> | Active link (set by TocAside JS) |
TocAside (sidebar)
Wraps TocList in a sticky card with border, scroll container, and IntersectionObserver active-link highlighting. Used in sidebar positions alongside long-form content.
Props
All TocList props plus:
| Prop | Type | Default | Notes |
|---|---|---|---|
headingLevel | 1–6 | 2 | Semantic heading level for the label |
activeLinks | boolean | true | Enables IntersectionObserver active-link highlighting |
Sticky positioning
Uses position: sticky with top: var(--pt-layout-scroll-margin) so it follows the reader down long pages while staying below the sticky header. The card constrains its height to the viewport minus scroll margin and uses the .toc-aside__scroll region as the internal scroll container.
Active-link highlighting
When activeLinks is true (default), an IntersectionObserver watches the headings targeted by each TOC link. The topmost visible heading’s link receives the toc-list__link--active class, applying link color and semibold weight.
The observer uses a setTimeout(0) defer on DOMContentLoaded to ensure it runs after any client-side fallback TOC script. Set activeLinks={false} to disable highlighting on a specific instance.
CSS class names
| Class | Element | Purpose |
|---|---|---|
toc-aside | <div> | Root card element |
toc-aside__label | Heading | Heading label |
toc-aside__scroll | <div> | Internal scroll container |
TocInline (collapsible)
Wraps TocList in a soft-background container with a vanilla JS collapsible toggle. Used in the main content header area of docs routes. No scroll, no sticky, no active-link tracking.
Props
All TocList props plus:
| Prop | Type | Default | Notes |
|---|---|---|---|
headingLevel | 1–6 | 2 | Semantic heading level for the label |
Collapse behavior
- Starts expanded (initial state:
open = true) - Toggle button wraps the heading label with a chevron-right icon that rotates 90° when open
- Uses CSS
grid-template-rowsfor smooth animation; hidden by default to prevent flash of collapsed state - Button uses
aria-expandedandaria-controlsfor accessibility
How headings reach the TOC
Astro’s render() function extracts headings from markdown content at build time and returns them as a MarkdownHeading[] array. Route templates pass this array to the TOC component; depth filtering happens inside TocList.
---
const { Content, headings } = await render(entry);
---
<TocInline
headings={headings}
startLevel={tocStartLevel}
endLevel={tocEndLevel}
/>
Important: render() only extracts headings authored in markdown (## Heading). Headings rendered by the Heading.astro component in .astro templates are not included — they exist in the template, not in the content.
Frontmatter controls
Content authors control TOC behavior through three frontmatter fields defined in the content schema:
| Field | Type | Default (articles/pages) | Default (docs) | Notes |
|---|---|---|---|---|
toc | boolean | true | true | Set to false to suppress TOC |
tocStartLevel | number | 1 | 2 | Lowest heading level included in TOC |
tocEndLevel | number | 2 | 3 | Highest heading level included in TOC |
Example — show only H2 headings in the TOC:
---
toc: true
tocStartLevel: 2
tocEndLevel: 2
---
TOC exclusion
Individual headings can be excluded from the TOC using the data-toc="exclude" HTML attribute. The Heading primitive supports this via its toc prop:
<!-- This heading appears in the TOC (default) -->
<Heading level={2} id="visible">Visible heading</Heading>
<!-- This heading is excluded from the TOC -->
<Heading level={2} id="hidden" toc={false}>Hidden from TOC</Heading>
When toc={false}, the Heading component renders data-toc="exclude" on the heading element. The docs route client-side fallback script filters out any heading with this attribute.
Known limitation
Per-heading TOC exclusion works only for component-rendered headings (<Heading toc={false}>) in .astro templates. Markdown-authored headings (## Foo) cannot carry the data-toc="exclude" attribute because Astro’s render() returns headings as { depth, slug, text } with no custom attribute passthrough. Markdown authors can control TOC scope coarsely via tocStartLevel/tocEndLevel frontmatter, but not per-heading.
Client-side fallback (docs route)
The docs route includes a fallback mechanism for cases where render() returns no headings but the TOC is enabled. When allowEmpty is true and data-toc-fallback="true" is set on the component, a client-side script:
- Queries rendered headings from the
.doc-body__contentelement (the LayoutSection<article>on docs detail routes) - Filters by the configured
tocStartLevel/tocEndLevelrange - Excludes headings with
data-toc="exclude" - Auto-generates
idattributes for headings that lack them - Populates the TOC list dynamically
This ensures the TOC works even when heading data isn’t available at the server-rendering stage.
Styling
Both wrappers use semantic and primitive tokens:
- Surface:
--st-color-surface-softbackground - Border (TocAside only):
--st-color-border-defaultwith--pt-border-width-thin - Links:
--st-color-text-muteddefault,--st-color-link-hoveron hover,--st-color-surface-stronghover background - Focus:
--st-color-focus-ringwith--pt-focus-ring-widthand--pt-focus-ring-offset - Active link (TocAside only):
--st-color-linkcolor,--pt-font-weight-semiboldweight
Accessibility
TocListrenders a<nav>witharia-labelfor screen reader identification- TOC links are keyboard-navigable with visible focus rings
TocInlinetoggle button usesaria-expandedandaria-controls- Heading labels use the
Headingprimitive with level-based size cascade for document outline correctness