jixoai-ui
the terminal design language
loading the style sheet…
Skip to content
docs · print medium: screen

registry:ui · engine

toc — the rule tracker rail

The reading rail as a composed family: manual mode composes TocList/TocItem/TocLink(child) — a legal list tree (anchors never nest); AUTO mode (outline: TocOutlineConfig) derives from headings at runtime — the DECLARED SSR exception (rail shell first, links on hydrate). Scrollspy is Root behavior, DOM-delegated.

install — toc

$npx jixoai-ui add toc

https://ui.jixoai.com/r/toc.json

IoM weights line pick TocList / TocItem / TocLink auto outline + composed tree this page's rail is the component

overview

Overview — and a named self-reference

toc tracks its own page, so every claim on this page names its channel: the PAGE-toc channel (+page.ts data the root layout renders) or the COMPONENT channel (the family itself — which is the rail on the right side of this very page).

Two toc channels exist on every docs route, and they are easy to confuse. The PAGE-toc channel: a page's +page.ts exports load data shaped TocSection[] — an interface this family exports — and the ROOT LAYOUT renders one rail from it in the scaffold's chrome cell. The COMPONENT channel: the toc family itself — the Toc root plus TocList / TocItem / TocLink parts. THIS route exports no load: its aside rail is the component in manual mode, demoing the real thing. A load may also return { toc: 'outline' } — the layout rail then self-derives from #main; that third form is the layout seat's capability, not yet any route's practice (the fleet's authored rails are all arrays — 107 at the task-79 read, 120 today; the digit drifts with the campaign). When you read "the rail" below, it means the component; when you read "the layout rail", it means the page-data channel.

The rail tracks by IoM weight and picks by line. Weights: intersection-over-minimum — the intersection area divided by the smaller of the block and the viewport, so a short block fully visible weighs the same 100% as a tall one; both directions saturate. The pick: whichever region contains the viewport-top LINE (margins resolve downward to the next block — the line never floats in dead space), painted as the bold marker plus aria-current, with the pick's parent inheriting the marker. The engine is DOM-delegated: no registration exists — the root re-queries its own subtree per update, so keyed reorders and conditional links just work, and a nested rail's links never leak in (the own-rail law: a link belongs to the rail whose root is its closest data-jx-toc-root).

Two modes over one family of parts. AUTO: outline={ root } derives the link tree from a content root's headings at runtime (toc-outline lib: DOM in, plain data out, zero listeners) — the DECLARED SSR exception: the server paints the rail shell, links arrive on hydrate. MANUAL: the composed list tree, SSR-complete. The axes are a FIRST-TIME all-no-own contract (the rail is placement chrome; size echoes on the root); theme is a declarative stamp over the page bridge; motion/elevation/color supply unread. Placement law, learned on this very page: an outline root cannot live inside a component canvas — the canvas stamps data-toc-skip (demo headings must never leak into page outlines), and a skipped root derives zero. The workbench below therefore runs as plain page markup, not a canvas demo. Kinship: website-scaffold (the shell whose chrome cell hosts the layout rail).

live demo

The workbench — two live rails, zero handwritten ids

The article below is tracked twice: the page rail on the right is the MANUAL composed tree; the rail beside the article runs AUTO outline mode — it derived its links from these very headings on hydrate (they carry runtime-stamped ids — inspect them). Scroll and watch both: nodes fill by IoM weight, the bold marker is the viewport-top line pick.

what it tracks

Scroll this page and watch the rails: nodes fill proportionally to how much of their block occupies the viewport — a half-visible heading block reads ~50%, not a binary in-view flag. Both directions saturate: cover the viewport entirely and the weight is 100%.

IoM weights

Intersection-over-minimum: the intersection area divided by the smaller of the block and the viewport. Tall blocks don't dwarf small ones — a short block fully visible weighs the same 100% as a tall block filling the screen.

the line pick

The bold marker is the viewport-top LINE: whichever region contains it is the pick. Margins between blocks resolve downward to the next block — the line never floats in dead space. On mobile the line sits at the sticky rail's bottom edge.

two modes, one family

MANUAL composes TocList (ul) / TocItem (li) / TocLink (a) — SSR-complete, nesting is a TocList inside a TocItem, anchors never nest. AUTO hands the rail a content root and the outline lib derives the same tree from the headings at runtime and renders through the same parts — the DECLARED SSR exception: the server paints the rail shell, links arrive on hydrate. The old sections[] prop is gone.

the engine

toc-engine.ts is framework-free (no framework imports — the engine owns the scroll/resize listener pair; the derivation lib toc-outline.ts is the listener-free half: DOM in, plain data out), and the family talks to it entirely through the DOM: the root re-queries its own subtree per update (no registration — keyed reorders and conditional links just work), derives each link's target from its href fragment, and synthesizes heading-to-heading extents for the engine. Scrollspy, aria-current, and the parent marker are root behavior in both modes.

usage
<script lang="ts">
  import Toc, { TocList, TocItem, TocLink } from '@ui/toc/index';
</script>

<!-- AUTO: derive from the content's headings (SSR paints the rail
     shell; links arrive on hydrate — the declared exception).
     PLACEMENT LAW: the content root cannot live inside a component
     canvas — the canvas stamps data-toc-skip, and a skipped root
     derives zero. -->
<aside class="jx-toc-aside">
  <Toc outline={{ root: 'main' }} title="on this page" />
</aside>

<!-- MANUAL: the composed list tree — SSR-complete; nesting is a
     TocList inside a TocItem, anchors never nest -->
<aside class="jx-toc-aside">
  <Toc title="on this page">
    <TocList>
      <TocItem><TocLink href="#setup">Setup</TocLink></TocItem>
      <TocItem>
        <TocLink href="#usage">Usage</TocLink>
        <TocList>
          <TocItem><TocLink href="#theming">Theming</TocLink></TocItem>
        </TocList>
      </TocItem>
    </TocList>
  </Toc>
</aside>

Why this workbench is not a canvas demo: the component canvas stamps data-toc-skip on its root (demo headings must never leak into page outlines — the outline law), and an outline root inside it derives ZERO. The measured receipts below include the before/after: the same AUTO rail inside the canvas rendered its empty shell forever. Placement is part of the API.

W3C foundation

What the platform gives

The rail is a labeled nav of real anchors: fragment links the browser crawls, focus the platform manages, and heading ids the anchor lands exactly. Scrollspy writes aria-current — it never steals focus or moves the scroll position under you.

usage
<script lang="ts">
  import Toc, { TocList, TocItem, TocLink } from '@ui/toc/index';
</script>

<!-- AUTO: derive from the content's headings (SSR paints the rail
     shell; links arrive on hydrate — the declared exception).
     PLACEMENT LAW: the content root cannot live inside a component
     canvas — the canvas stamps data-toc-skip, and a skipped root
     derives zero. -->
<aside class="jx-toc-aside">
  <Toc outline={{ root: 'main' }} title="on this page" />
</aside>

<!-- MANUAL: the composed list tree — SSR-complete; nesting is a
     TocList inside a TocItem, anchors never nest -->
<aside class="jx-toc-aside">
  <Toc title="on this page">
    <TocList>
      <TocItem><TocLink href="#setup">Setup</TocLink></TocItem>
      <TocItem>
        <TocLink href="#usage">Usage</TocLink>
        <TocList>
          <TocItem><TocLink href="#theming">Theming</TocLink></TocItem>
        </TocList>
      </TocItem>
    </TocList>
  </Toc>
</aside>

types

Types

Two modes over one family of parts: AUTO derives the tree from headings; MANUAL is the composed list tree.

AUTO · outline mode

outline={ root } derives the same tree from a content root's headings at runtime — zero handwritten ids; the declared SSR exception (rail shell paints, links arrive on hydrate). Placement law: the root cannot live inside a component canvas (data-toc-skip derives zero).

MANUAL · composed tree

TocList (ul) / TocItem (li) / TocLink (a) — SSR-complete; nesting is a TocList inside a TocItem, anchors never nest.

usage

Usage

Both modes in one copyable sample; the rail needs an aside shell and (overlay shells) the scrollRoot.

Toc usage
<script lang="ts">
  import Toc, { TocList, TocItem, TocLink } from '@ui/toc/index';
</script>

<!-- AUTO: derive from the content's headings (SSR paints the rail
     shell; links arrive on hydrate — the declared exception).
     PLACEMENT LAW: the content root cannot live inside a component
     canvas — the canvas stamps data-toc-skip, and a skipped root
     derives zero. -->
<aside class="jx-toc-aside">
  <Toc outline={{ root: 'main' }} title="on this page" />
</aside>

<!-- MANUAL: the composed list tree — SSR-complete; nesting is a
     TocList inside a TocItem, anchors never nest -->
<aside class="jx-toc-aside">
  <Toc title="on this page">
    <TocList>
      <TocItem><TocLink href="#setup">Setup</TocLink></TocItem>
      <TocItem>
        <TocLink href="#usage">Usage</TocLink>
        <TocList>
          <TocItem><TocLink href="#theming">Theming</TocLink></TocItem>
        </TocList>
      </TocItem>
    </TocList>
  </Toc>
</aside>

theming

Theming

Chrome sizing and the scrollspy paint are token-driven custom properties the engine writes at runtime; the mobile bar's depth rides the glass-effect stamp.

xs
sm
default
lg
TokenDefaultSource
--jx-toc-line76pxcomponent
--jx-cur0 | 1component
--jx-progress0 → 1component
--jx-chrome-bar44pxcomponent

api

API

Props from the Toc and TocList Props interfaces; TocItem/TocLink are the same shape passthrough. The root is ATTRIBUTE-TRANSPARENT: the rest spread (HTMLAttributes minus color) lands on the rail root — id, data-*, aria-* and handlers included.

Properties

PropertyTypeDefaultDescription
outline TocOutlineConfig—AUTO mode: derive the outline from a content root headings ({ root, levels? }). Placement law: the root cannot live inside a component canvas (data-toc-skip derives zero).
title string'reading progress'The desktop rail label.
scrollRoot string | HTMLElement | nulldocumentScroll root for overlay-shell layouts (selector or element).
children Snippet—MANUAL mode: the composed TocList tree.
class string''Class passthrough (root / list).
TocList: children * Snippet—TocItem children; nesting is a TocList inside a TocItem.
...rest HTMLAttributesspreadTocList spreads onto the ul; TocLink is a plain anchor with your href.

Universal props

PropertyTypeDefaultDescription
size'small' | 'medium' | 'large' | 'auto' | number'auto' · ambient scopethe base scale — root font-size; parts size in em
shape'round' | 'scoop' | 'bevel' | 'notch' | 'square' | 'squircle' | 'auto''auto' · ambient scopecorner geometry (CSS corner-shape; §14 degrade table)
radius'small' | 'medium' | 'large' | 'auto' | number'auto' · ambient scopecorner size; auto = the concentric broadcast (§3)
density'small' | 'medium' | 'large' | 'auto' | number'auto' · ambient scopespacing/leading scale over the kernel channels (§4)
color'primary' | 'secondary' | 'error' | 'warn' | 'success' | 'info' | 'auto' | number | string'auto' · ambient scopethe hue axis of the fixed oklch primary system (§5)
theme'light' | 'dark' | 'system' | 'auto''auto' · ambient scopelight/dark profile; system = the JS-mutable global (§6)
elevation'level-1' | 'level0' | 'level1' | 'level2' | 'level3' | 'level4' | 'level5' | 'auto' | number'auto' · ambient scopeofficial M3 levels over the surface ladder (§7)
motion'reduced' | 'subtle' | 'normal' | 'expressive' | 'auto' | number'auto' · ambient scopeintensity across the motion kernels (§8)

axes

The eight axes on toc

The FIRST-TIME all-no-own contract (the rail is placement chrome): density stamps the root and the ambient scope paints; size echoes on the rail root; theme is a declarative stamp over the page bridge; shape/radius/color/elevation/motion supply unread (zero carrier readers — the spy paint maps engine-written flags to the brand chain directly).

PropertyTypeDefaultDescription
density '2xs' | 'xs' | 'sm' | 'default' | 'lg' | 'auto' | number (+ the five legacy spellings)'auto'MANAGED STAMP, AMBIENT PAINT — the rung attr stamps the rail root (data-density; measured on the explicit rungs, absent at auto), and the rail’s own paint follows the ambient density scope through the consumed tokens rather than a family own. Zero --jx-density-effective readers (grep receipt). Number unit: coefficient.
size 'small' | 'medium' | 'large' | 'auto' | number'auto'ROOT ECHO — the §1 stamp scales the rail root and the List/Item/Link parts ride the ambient chain (吃也供; measured an 18px stamp reading 18px on the root). Zero --jx-size-effective readers (grep receipt). Number unit: px.
shape 'round' | 'scoop' | 'bevel' | 'notch' | 'square' | 'squircle' | 'auto''auto'SUPPLY-ONLY — zero shape-channel readers (grep receipt: no corner-shape or factor consumer in ui/toc/); the rail is square chrome. Number unit: none.
radius 'small' | 'medium' | 'large' | 'auto' | number'auto'SUPPLY-ONLY — zero radius-effective readers (grep receipt); the desktop spine is a hairline and the mobile bar is a full-bleed glass row (measured 0px corners on the root). Number unit: px.
color 'primary' | 'secondary' | 'error' | 'warn' | 'success' | 'info' | 'auto' | number | string'auto'SUPPLY-ONLY — the pick marker and the spine fill paint the brand chain directly (the engine writes --jx-cur/--jx-progress flags; the css maps them to the brand inks), with zero --jx-color-effective readers (grep receipt). Number unit: hue degrees.
theme 'light' | 'dark' | 'system' | 'auto''auto'DECLARATIVE STAMP, AND THE HOST DECIDES — theme="dark" stamps the .dark class on the rail root; the page bridge re-derives the rail inks page-wide (measured the rail title oklch(0.3211 0 0) → oklch(0.8452 0 0) under prefers-dark, task-69-era digits — the direction holds, re-measure at citation), and a canvas data-theme="light" island would pin a rail placed inside it (the host layer, measured on sibling lanes). system/auto = tree inheritance. No number lane.
elevation 'level-1' | 'level0' | 'level1' | 'level2' | 'level3' | 'level4' | 'level5' | 'auto' | number'auto'SUPPLY-ONLY — zero elevation-carrier readers (grep receipt); the mobile bar’s depth rides the glass-effect stamp (data-jx-effect="blur"), not a shadow tier. Number unit: dp.
motion 'reduced' | 'subtle' | 'normal' | 'expressive' | 'auto' | number'auto'SUPPLY-ONLY — zero --jx-motion-effective readers (grep receipt); the rail’s transitions (the mobile height transition, the spine fill) ride the consumed toc.css laws, and scrollspy itself is not animated — the pick jumps with the line. Number unit: coefficient.

Receipts: the channel census (six authored instances → nine served roots on this page — the DensityDemo multiplies the density sample across its scope wrappers; every demo specimen is a Toc root; the aside rail's composed tree renders twice by design, desktop spine + mobile viewport) and the scrollspy (weights 0 → 1 per link as --w, the pick's aria-current following the shell scroller, the spine fill --jx-progress live) were measured on this page's served DOM (probe, task 69); the outline lib's slug stamp, wrapper-twin adoption, duplicate -2 suffix, data-toc-skip, CJK positional fallback, two-tier collapse, idempotent re-derivation and heading-to-heading extents were exercised in-browser against a fixture; the theme strata (rail title oklch(0.3211 0 0) → oklch(0.8452 0 0) under the page bridge — task-69-era digits, re-measure at citation) the same way; the unread rows carry grep receipts over ui/toc/. The query() seat below rides the md viewport key (48rem) on the size lane.

one real query() case
<script lang="ts">
  import Toc from '@ui/toc/index';
  import { query } from '@lib/universal-props-query.svelte';
</script>

<!-- below 48rem the base (13px root) applies; at 48rem+ the md case (18px)
     wins — the rail voice scales, the spy behavior is unchanged -->
<Toc title="on this page" size={query({ md: 18 }, 13)}>
  <TocList><TocItem><TocLink href="#setup">Setup</TocLink></TocItem></TocList>
</Toc>

toc · query()

Media keys are min-width: below 48rem the base (13px root) applies; at 48rem and wider the md case wins (18px) — the rail voice scales while the spy behavior is unchanged. The number lane goes bare. Resize across 48rem.

toc-query-demo.svelte svelte
<script lang="ts">
  import Toc from '@ui/toc/index';
  import { query } from '@lib/universal-props-query.svelte';
</script>

<!-- below 48rem the base (13px root) applies; at 48rem+ the md case (18px)
     wins — the rail voice scales, the spy behavior is unchanged -->
<Toc title="on this page" size={query({ md: 18 }, 13)}>
  <TocList><TocItem><TocLink href="#setup">Setup</TocLink></TocItem></TocList>
</Toc>

Toc · universal props

toc-universal.svelte svelte
<Toc title="on this page" size={18} density="small">…</Toc>

a11y

Accessibility

A labeled nav of real anchors; scrollspy writes aria-current, never focus.

Keyboard

KeyAction
TabReaches the rail links in tree order; focus is never stolen by scrollspy
EnterFollows the anchor — the heading lands exactly on the pick line

ARIA

AttributeValueDescription
aria-label"Table of contents"On the desktop nav landmark (plus your aside label)
aria-current"true"Written on the active link(s) by the engine each update
aria-expandedbooleanOn the mobile bar disclosure toggle ("Expand table of contents")
jixoai-ui ui.jixoai.com the jixoai design language

Theme

Brand hue

0°

Auto cycle · 24h