Skip to content

Toc

A table-of-contents rail with automatic heading collection, nested levels, scroll-spy, smooth scroll, and an optional mobile collapse. It is the docs site's own 'On this page' rail, generalized behind props.

Import

import { Toc } from "@hyzer-labs/ui"

Demo

Default levels is [2], so only h2s. Scroll the article or click a link: the active entry tracks the reading position. That is the last heading at or above the top quarter of the viewport, pinned to the last entry at the bottom.

Choosing a disc

Distance drivers cut through the wind but punish a bad release; putters forgive one and go nowhere fast. Most rounds live in between, with a fairway driver or a midrange doing the actual work.

Plastic matters less than flight numbers early on: speed, glide, turn, fade. Learn one disc in each class before buying a bag full of near-duplicates.

A new disc flies overstable and settles in as the plastic wears. The "beat-in" version of a mold often flies nothing like the fresh one on the shelf.

Weight changes flight more than most players expect: a 165g and a 175g version of the same mold are, in practice, two different discs.

Grip and stance

A fan grip trades power for control; a power grip does the opposite. Neither is wrong. The disc does not know which fingers are on the rim, only how fast it is spinning when it leaves them.

Stagger your feet, load the hips, and let the arm follow. A rushed pull is the single most common source of early, weak turnover.

Reach back too far and the disc arrives late and flat; reach back too little and there's no room left to accelerate through the release.

A relaxed grip through the whole pull beats a white-knuckle one at the start. Tension in the wrist bleeds spin, and spin is what keeps the disc stable.

Reading the wind

A headwind exaggerates turn and fade alike, so understable discs flip over faster than expected. A tailwind flattens everything out, and what looked overstable on a calm day suddenly holds its line.

Crosswinds are the honest test: throw into one on a hyzer line and the disc tells you, immediately, whether it's actually as stable as the flight numbers claim.

Tree lines break up gusts unevenly. The wind at chest height rarely matches the wind the flag at the tee box is reading.

<article class="content">
	<h2>Choosing a disc</h2>
	<h2>Grip and stance</h2>
	<h2>Reading the wind</h2>
</article>

<Toc container=".content" />

Props

NameTypeDefaultNote
containerstring | HTMLElement'main'Selector or element to collect headings from.
levelsnumber[][2]Heading levels to collect — 2 for h2, 3 for h3, and so on.
excludestring | string[]Headings inside a match are skipped (e.g. demo/example regions). Pass an array to combine several selectors.
minEntriesnumber2Renders nothing below this many entries — a single link is not navigation.
titlestring'On this page''' hides it.
ariaLabelstring— (defaults to title)Names the nav landmark.
autoIdbooleantrueSlugify id-less headings (kebab, deduped with -2, -3 …). false skips them instead.
watchbooleantrueRe-collects on DOM mutation (a MutationObserver on container, debounced). No framework coupling — no $app/* import, so it works the same in any SvelteKit or plain-Svelte app.
smoothScrollbooleantrueInstant under prefers-reduced-motion regardless.
breakpoint'sm' | 'md' | 'lg' | 'none''none'Collapses into a disclosure below this width (640/968/1200px). none never collapses.
activestring''Bindable id of the current heading (scroll-spy — or the last clicked link).
onActive(id: string) => voidFires when the active heading changes — not on every scroll frame.
classstringMerged after the hz-toc class.

TocEntry

NameTypeDefaultNote
idstringThe heading's id — its own, or an autoId slug.
labelstringThe heading's trimmed text content.
levelnumberThe collected heading level (2, 3, …).

Theme hooks

What this component promises your CSS. The reference theme styles exactly these — from @layer hz-theme, so your unlayered rules win. See Styling Components for the how.

Root class: .hz-toc

Data attributes

HookValuesStyles
data-breakpoint'sm' | 'md' | 'lg' | 'none'Which width the rail collapses below into a disclosure. none (default) never collapses. A real @media breakpoint, not a container query — the rail’s own box is typically narrow regardless of page width.
data-collapsedpresent while the mobile disclosure is closedOnly stamped when data-breakpoint is not none. Absent once the panel opens, and inert above the breakpoint, where the panel is always shown.
[aria-current]'location'On .hz-toc-link — the scroll-spy’s active entry. Not a data-* hook.
data-level (on .hz-toc-link)<number> — the collected heading level (2, 3, …)Per-entry, not root-level — drives the indent.

Part classes

HookValuesStyles
.hz-toc-titlechild elementThe plain (non-heading) title.
.hz-toc-triggerchild elementThe mobile disclosure button (title + chevron). Always rendered, shown only below the breakpoint — the theme swaps it in for .hz-toc-title by data-breakpoint and the viewport, the Header toggle precedent.
.hz-toc-panelchild elementWraps the list; what the trigger shows/hides below the breakpoint.
.hz-toc-listchild elementA nested <ul>, one per level below the top.
.hz-toc-linkchild elementOne entry.

Accessibility

The root is a nav landmark named by ariaLabel (which defaults to title). Give every Toc on a page a distinct name if there's more than one.

The active entry carries aria-current="location", updated by the scroll-spy and by clicking a link.

In collapse mode, the trigger is a real disclosure button (aria-expanded/aria-controls). Escape closes it and returns focus to the trigger, and an outside click closes it without stealing focus from wherever the click landed.

Motion respects prefers-reduced-motion: smoothScroll degrades to an instant jump automatically.

References: APG Disclosure pattern · MDN: aria-current