An "On this page" sidebar for long documents. Give Toc.Root a flat list of headings and it draws a depth-indented list of anchor links over a curved spine, sliding an accent marker to the heading currently in view as the reader scrolls. Toc.Footer adds an optional column of page-action rows.
Headless basis
Noctis heading-spine engine
Internationalization
Localized
Built on
Primitive
Consumed by
None
Aurora borealis
Charged particles meet the upper atmosphere, and it glows.
Solar wind
Plasma leaves the corona at roughly 400 kilometres a second.
Magnetosphere
Earth's field turns most of that stream aside and funnels the rest poleward.
The auroral oval
The glow rings each magnetic pole, widening as a storm intensifies.
Colour and altitude
Oxygen greens near 100 km and reds above 200; nitrogen edges the curtain violet.
Toc.Root takes the items — a flat { title, url, depth }[], the shape every markdown-TOC pipeline already emits. Inside it, Toc.Header is the "On this page" label and Toc.Items is the engine that renders the links and tracks the active heading as the reader scrolls. Every heading currently in view is highlighted, so the marker spans the whole on-screen range rather than snapping to one line.
Depth is purely visual: a deeper depth indents the row and steps the spine one level right, drawing a small diagonal connector at each change. The line clamps at one level of nesting (so it never marches off to the right) while the text keeps indenting. The accent marker is masked to the spine, so the active stretch of the tree lights up.
nested.tsx
"use client";import { Toc } from "@stridge/noctis/toc";// A deeper outline: h2 sections with h3/h4 children. The spine steps right as the depth grows and// draws a diagonal connector at each change; `Tokens` is marked active so the marker is visible.const ITEMS = [ { title: "Installation", url: "#installation", depth: 2 }, { title: "Theming", url: "#theming", depth: 2 }, { title: "Tokens", url: "#tokens", depth: 3 }, { title: "Theme engine", url: "#theme-engine", depth: 3 }, { title: "Dark mode", url: "#dark-mode", depth: 4 }, { title: "Components", url: "#components", depth: 2 }, { title: "Overlays", url: "#overlays", depth: 3 }, { title: "Motion", url: "#motion", depth: 2 },];export default function TocNested() { return ( <Toc.Root items={ITEMS} activeIds={["tokens"]}> <Toc.Header /> <Toc.Items /> </Toc.Root> );}
The real shape: a long article with a sticky table of contents beside it. By default the active marker tracks against the window as the page scrolls. When the document scrolls inside its own container instead — a height-capped panel, a dialog body — pass that container's ref as scrollRoot and tracking observes it rather than the viewport (the example below is capped so it scrolls in place). The sticky positioning and the column width belong to your layout, not the component.
Installation
Add @stridge/noctis to your app and import the framework-neutral stylesheet once at the root. There's no build step and no styling framework to adopt.
Provider
Wrap your tree in NoctisProvider — it seeds the theme, locale, and direction every component reads from, so a single root configures the whole interface.
Theming
Noctis derives every surface from one seed through the OKLCH theme engine, so a single accent retunes the entire interface without touching component code.
Tokens
Semantic tokens — background, foreground, accent, border — are the only values you reference; the primitive ramp stays private to the engine.
Theme engine
The engine resolves contrast per mode, lifting surfaces toward white as they elevate and holding text legible at any seed.
Dark mode
Dark is the default; light mode is derived from the same seed rather than maintained as a second palette.
Components
Every component ships from its own deep import — @stridge/noctis/button, @stridge/noctis/dialog — as precompiled, framework-neutral CSS keyed off data-slot.
Internationalization
Strings, direction, and number formatting flow through @stridge/noctis-intl, so right-to-left and locale support are structural rather than bolted on.
Motion
Restrained, named motion scales drive every transition, and each one respects prefers-reduced-motion out of the box.
Accessibility
Keyboard operation, focus rings, and ARIA are built into every primitive — the documentation site dogfoods the same components it describes.
with-content.tsx
"use client";import { Toc } from "@stridge/noctis/toc";import { useRef } from "react";// A realistic two-column layout, capped to a fixed height so the article scrolls *inside* the box (its// own scrollbar) instead of the page. `scrollRoot` points the active-tracking at that container, so the// marker follows the heading in view as you scroll within it. Both lists derive from one `SECTIONS`// array so they can't drift.const SECTIONS = [ { id: "installation", title: "Installation", depth: 2, body: "Add @stridge/noctis to your app and import the framework-neutral stylesheet once at the root. There's no build step and no styling framework to adopt.", }, { id: "provider", title: "Provider", depth: 2, body: "Wrap your tree in NoctisProvider — it seeds the theme, locale, and direction every component reads from, so a single root configures the whole interface.", }, { id: "theming", title: "Theming", depth: 2, body: "Noctis derives every surface from one seed through the OKLCH theme engine, so a single accent retunes the entire interface without touching component code.", }, { id: "tokens", title: "Tokens", depth: 3, body: "Semantic tokens — background, foreground, accent, border — are the only values you reference; the primitive ramp stays private to the engine.", }, { id: "theme-engine", title: "Theme engine", depth: 3, body: "The engine resolves contrast per mode, lifting surfaces toward white as they elevate and holding text legible at any seed.", }, { id: "dark-mode", title: "Dark mode", depth: 4, body: "Dark is the default; light mode is derived from the same seed rather than maintained as a second palette.", }, { id: "components", title: "Components", depth: 2, body: "Every component ships from its own deep import — @stridge/noctis/button, @stridge/noctis/dialog — as precompiled, framework-neutral CSS keyed off data-slot.", }, { id: "internationalization", title: "Internationalization", depth: 2, body: "Strings, direction, and number formatting flow through @stridge/noctis-intl, so right-to-left and locale support are structural rather than bolted on.", }, { id: "motion", title: "Motion", depth: 2, body: "Restrained, named motion scales drive every transition, and each one respects prefers-reduced-motion out of the box.", }, { id: "a11y", title: "Accessibility", depth: 2, body: "Keyboard operation, focus rings, and ARIA are built into every primitive — the documentation site dogfoods the same components it describes.", },];const items = SECTIONS.map(({ title, id, depth }) => ({ title, url: `#${id}`, depth }));export default function TocWithContent() { const scrollRoot = useRef<HTMLDivElement>(null); return ( <div ref={scrollRoot} className="h-96 overflow-y-auto rounded-lg border border-border p-6"> <div className="flex gap-8"> <article className="min-w-0 flex-1 space-y-8"> {SECTIONS.map((section) => { const Heading = section.depth === 2 ? "h2" : "h3"; return ( <section key={section.id}> <Heading id={section.id} className="scroll-mt-6 font-semibold"> {section.title} </Heading> <p className="mt-2 text-muted">{section.body}</p> </section> ); })} </article> <aside className="sticky top-0 h-fit w-56 shrink-0 max-md:hidden"> <Toc.Root items={items} scrollRoot={scrollRoot}> <Toc.Header /> <Toc.Items /> </Toc.Root> </aside> </div> </div> );}
color sets the active link and marker fill: primary (the default — the neutral white signal that carries most of the interface) or the vivid accent held for emphasis. Everything else stays muted either way, so the active heading is the only thing that pops.
color.tsx
"use client";import { Toc } from "@stridge/noctis/toc";const ITEMS = [ { title: "Installation", url: "#installation", depth: 2 }, { title: "Theming", url: "#theming", depth: 2 }, { title: "Tokens", url: "#tokens", depth: 3 }, { title: "Components", url: "#components", depth: 2 },];// `color` sets the active link + marker fill: `primary` (the default neutral white signal) or the vivid// `accent`. Everything else stays muted either way.export default function TocColor() { return ( <div className="flex gap-12"> <Toc.Root items={ITEMS} activeIds={["theming"]} color="primary"> <Toc.Header>Primary (default)</Toc.Header> <Toc.Items /> </Toc.Root> <Toc.Root items={ITEMS} activeIds={["theming"]} color="accent"> <Toc.Header>Accent</Toc.Header> <Toc.Items /> </Toc.Root> </div> );}
Toc.Footer is a generic, composable region — a labelled <nav> holding a column of Toc.Action rows. Each action is a ghost button by default, or an anchor via render; place a leading glyph and a <span> label inside and wire the behaviour (edit, copy, scroll-to-top, feedback…) to your app.
actions.tsx
"use client";import { CircleArrowUpIcon, CopyIcon, ExternalLinkIcon, Icon, MessageCircleIcon, SquarePenIcon, ThumbsUpIcon,} from "@stridge/noctis/icon";import { Toc } from "@stridge/noctis/toc";const ITEMS = [ { title: "Installation", url: "#installation", depth: 2 }, { title: "Theming", url: "#theming", depth: 2 }, { title: "Components", url: "#components", depth: 2 },];// The footer is a generic composable region: each row is a `Toc.Action` (a ghost button, or an anchor// via `render`) holding a leading glyph and a `<span>` label. Wire the behaviour to your app.export default function TocActions() { return ( <Toc.Root items={ITEMS} activeIds={["installation"]}> <Toc.Header /> <Toc.Items /> <Toc.Footer> <Toc.Action render={<a href="https://github.com" aria-label="Edit this page" />}> <Icon icon={SquarePenIcon} size="sm" /> <span>Edit this page</span> </Toc.Action> <Toc.Action> <Icon icon={CircleArrowUpIcon} size="sm" /> <span>Scroll to top</span> </Toc.Action> <Toc.Action> <Icon icon={ThumbsUpIcon} size="sm" /> <span>Give feedback</span> </Toc.Action> <Toc.Action> <Icon icon={CopyIcon} size="sm" /> <span>Copy page</span> </Toc.Action> <Toc.Action> <Icon icon={MessageCircleIcon} size="sm" /> <span>Ask AI about this page</span> </Toc.Action> <Toc.Action> <Icon icon={ExternalLinkIcon} size="sm" /> <span>Open in chat</span> </Toc.Action> </Toc.Footer> </Toc.Root> );}
By default the active heading is tracked automatically. Pass activeIds to control it instead — the built-in observer steps aside — and onActiveChange to observe the set the observer would compute. activeMode chooses between highlighting the whole on-screen range (multi, the default) or clamping it to the topmost heading in view (single).
Toc.Root is a <nav> landmark named "On this page" (localized; pass aria-label to override). The links are real in-page anchors, so they are keyboard reachable and carry native hash history; the active link is marked data-active (a styling hook, not an ARIA state). The spine, its connectors, and the moving marker are decorative (aria-hidden / role="none"). Toc.Footer is a second landmark named "Page actions", and each Toc.Action is a real <button>/<a> with a focus-visible ring. The whole component mirrors under RTL by construction.
Compose the table of contents from its parts. Toc.Root owns the data; Toc.Items owns the rendering and the runtime engine.
Toc.Root — the <nav> landmark; takes items, activeMode, color, the optional scrollRoot, and the optional controlled activeIds/onActiveChange.
Toc.Header — the "On this page" heading; override via children.
Toc.Items — the scrolling viewport that draws the spine, the moving marker, and the link rows, and tracks the active heading.
Toc.Footer — the optional page-actions region (a labelled <nav>).
Toc.Action — one page-action row; a ghost <button> by default, composable onto an <a> via render.
Every rendered part carries a data-slot (noctis-toc on the root, noctis-toc-link on a link, and so on) for host-side styling. The link's depth indent, the spine mask, and the marker position are runtime values written as inline style, since they depend on measured layout.
The same table of contents re-tuned across the elevation scopes — the root canvas, an elevated panel, a menu, and a sunken well. It stays legible on every layer.
Generated from the component's declaration — the same graph that mints the CSS, so a variable name or its resolution default can't drift. The minted tokens are the public override seam: set one on any ancestor and every table of contents in that region retunes — e.g. .docs { --noctis-toc-link-padding-block: var(--noctis-space-3); } opens up the rows. Colours aren't minted — the link reads the muted/foreground roles directly, the spine reads border, and the active link + marker read primary/accent keyed off the color axis — so a retheme propagates for free. See Customization for the full override ladder and Tokens for the whole graph.
Generated from the component's types — every prop, type, default, and description comes straight from the source. Each part gets its own table. Expand a row for the full type and description.
Toc.Root
PropTypeDefault
Toc.Header
PropTypeDefault
Toc.Items
PropTypeDefault
Toc.Footer
PropTypeDefault
Toc.Action
PropTypeDefault
AttributeDescription
data-slotThe slot marker on every rendered part (`noctis-toc`, `noctis-toc-link`, …).
data-colorThe active-fill colour axis — `primary` (default) | `accent`, stamped on the root; the active link + thumb key off it.
data-activePresent as `"true"` on the active link (and `"false"` otherwise); `toc.css` lifts it to the active colour.
aria-currentSet to `"location"` on the active link so assistive tech announces it as the current section.
data-depthThe heading depth of a link (2 = h2, 3 = h3, …), stamped on `Toc.Link` so the spine geometry can be derived.
data-trim-startPresent on a link's spine `marker` when the depth differs from the link above — trims the divider's top to meet the diagonal.
data-trim-endPresent on a link's spine `marker` when the depth differs from the link below — trims the divider's bottom to meet the diagonal.