Skip to main content
astrocraft-ui/ components · 101

Table Of Contents

Free · MIT

Headless, accessible, and styled entirely through the attribute surface below — bring your own CSS system, or start from the reference theme.

Install

Without a flag you get no styling at all — plain HTML carrying the attribute surface below. --theme copies the same theme in two dialects;--bridge lumos re-points either one onto Lumos for Astro's tokens (it needs a theme to re-point, and Lumos itself, already in your project).

Unstyled — behavior only, you write the CSS

Install command
npx astrocraft-ui add navigation/table-of-contents

Plain-CSS theme — no build step

Install command
npx astrocraft-ui add navigation/table-of-contents --theme css

Tailwind theme — needs Tailwind v4

Install command
npx astrocraft-ui add navigation/table-of-contents --theme tailwind

Plain-CSS theme, re-pointed onto Lumos tokens

Install command
npx astrocraft-ui add navigation/table-of-contents --theme css --bridge lumos

Tailwind theme, re-pointed onto Lumos tokens

Install command
npx astrocraft-ui add navigation/table-of-contents --theme tailwind --bridge lumos

Live demo

TableOfContents & ScrollSpy

The links are plain fragment links and work with JavaScript off. ScrollSpy is the wrapper that marks one aria-current="location" as you scroll — and keeps it marked through the whole section, not only while the heading is on screen. Scroll the page and watch it follow.

Install

One dependency, no peer dependencies, no build step of its own. The primitives are .astro files; Astro compiles them with the rest of your site.

Structure

Import structure.css once. It is behavior, not looks — every rule in it says what breaks when you delete it.

Theme

theme-default.css is optional and is the worked example. You are meant to read it and write your own against the same attributes.

Acceptance gate

Unimport the theme and drive everything. Anything that stops working rather than merely stops looking right is a rule that belongs in structure.css.

Attribute surface

With no class-merging machinery, these attributes are the whole extension point. State is a data-attribute or a native attribute; visibility is the hiddenattribute. Generated from source by pnpm slots.

ComponentSlotsVariantsRuntime stateNative state
`ScrollSpy.astro``scroll-spy`———
`TableOfContents.astro``table-of-contents`———
`TocItem.astro``toc-item` `toc-link``data-level`: `2` · `3` · `4`—`[aria-current]`

Source

What the command copies — 5 files, alias-free so the copy lands standing alone. This is the whole component; there is nothing else.

src/components/ui/navigation/table-of-contents/ScrollSpy.astro
---
// src/components/ui/navigation/table-of-contents/ScrollSpy.astro — TableOfContents compound part, and a
// standalone behavior (see ../../README.md).
//
// Wrap it around ANY run of links whose `href` is a fragment — a TableOfContents, a docs sidebar,
// a set of section anchors — and the one matching the section on screen is marked
// `aria-current="location"` while the rest read `"false"`. It renders a plain box and nothing else;
// all of it is in the script.
//
// It is a wrapper rather than a prop on TableOfContents because the behavior and the markup are
// genuinely separate concerns: a sidebar that is not a TOC wants the marking, and a TOC printed into
// a PDF does not.
//
// `band` is how far down the viewport the "reading line" sits, as a percentage. Headings between the
// top and that line are candidates; below it they have not been reached yet. 30% is a reading
// position rather than the very top edge, which would only ever mark a heading during the one frame
// it crosses y=0.
//
// No motion, no scroll listener, no `scroll-behavior` opinion: an IntersectionObserver reports the
// band, and geometry answers the rest (see active-index.ts for why both are needed). A progress bar
// on top of this is a scroll-driven CSS animation and belongs to the theme — with the reduced-motion
// escape that ../_overlay.css and Reveal both document, since zeroing a duration does not stop one.
import type { HTMLAttributes } from "astro/types";

type Props = HTMLAttributes<"div"> & {
  /** Reading line, as a percentage of viewport height from the top. */
  band?: number;
};

const { band = 30, class: className, ...rest } = Astro.props;
---

<div class={className} data-slot="scroll-spy" data-band={band} {...rest}>
  <slot />
</div>

<script>
  import { onReadyOnce } from "../../_once";
  import { activeIndex } from "./active-index";

  function wire(root: HTMLElement) {
    const links = [...root.querySelectorAll<HTMLAnchorElement>('a[href^="#"]')];
    // A link whose target is not on the page is not an error — a shared sidebar legitimately points
    // at other pages — it just is not spied on. Keep the pairs, drop the rest.
    const pairs = links
      .map((link) => ({
        link,
        target: document.getElementById(decodeURIComponent(link.hash.slice(1))),
      }))
      .filter(
        (pair): pair is { link: HTMLAnchorElement; target: HTMLElement } => pair.target !== null,
      );
    if (pairs.length === 0) return;

    const inBand = new Set<Element>();

    const mark = () => {
      const i = activeIndex(
        pairs.map((pair) => inBand.has(pair.target)),
        // `<= 1` rather than `<= 0`: a heading sitting exactly on the top edge rounds either way
        // between engines, and a one-pixel tolerance costs nothing.
        pairs.map((pair) => pair.target.getBoundingClientRect().top <= 1),
      );
      pairs.forEach((pair, j) => {
        pair.link.setAttribute("aria-current", j === i ? "location" : "false");
      });
    };

    // The band is the top `data-band`% of the viewport: everything below it is cropped away by the
    // negative bottom margin, so `isIntersecting` means "inside the reading strip".
    const band = Number(root.dataset.band) || 30;
    const observer = new IntersectionObserver(
      (entries) => {
        for (const entry of entries) {
          if (entry.isIntersecting) inBand.add(entry.target);
          else inBand.delete(entry.target);
        }
        mark();
      },
      { rootMargin: `0px 0px -${100 - band}% 0px` },
    );
    for (const pair of pairs) observer.observe(pair.target);
    mark();
  }

  onReadyOnce('[data-slot="scroll-spy"]', wire);
</script>
src/components/ui/navigation/table-of-contents/TableOfContents.astro
---
// src/components/ui/navigation/table-of-contents/TableOfContents.astro — headless primitive (see ../../README.md).
// The in-page contents list: a <nav> landmark named "Table of contents" wrapping an <ol>, because
// the order and the nesting of headings are the content, not a look. Zero-JS on its own.
//
// It does NOT spy on the page by itself. Wrap it in <ScrollSpy> for that — the marking behavior is a
// separate file precisely so it can also be wrapped around a sidebar, a set of section tabs, or any
// other run of links pointing at ids on the page.
//
//   <ScrollSpy>
//     <TableOfContents>
//       <TocItem href="#install">Install</TocItem>
//       <TocItem href="#options" level={3}>Options</TocItem>
//     </TableOfContents>
//   </ScrollSpy>
import type { HTMLAttributes } from "astro/types";

type Props = HTMLAttributes<"nav">;

const { class: className, ...rest } = Astro.props;
---

<nav aria-label="Table of contents" data-slot="table-of-contents" {...rest}>
  <ol class={className}>
    <slot />
  </ol>
</nav>
src/components/ui/navigation/table-of-contents/TocItem.astro
---
// src/components/ui/navigation/table-of-contents/TocItem.astro — TableOfContents compound part (see ../../README.md).
// One entry. `href` is a fragment pointing at the heading's id — ScrollSpy resolves the target from
// exactly that, so there is no second list of selectors to keep in step with this one.
//
// `level` is the heading's own depth, rendered as `data-level` for indentation. It is a variant, so
// a theme can indent with `[data-slot="toc-item"][data-level="3"]` rather than nesting a second <ol>
// — which would be the other way to say it, and would mean a TOC of two shapes to style.
//
// `aria-current` ships as `"false"` rather than being absent: ScrollSpy swaps it to `"location"`, and
// an attribute that is always present is one a theme can style without `:not([aria-current])`.
// `location` — not `page` — is the right token here. The page has not changed; the position in it has.
import type { HTMLAttributes } from "astro/types";

type Props = HTMLAttributes<"a"> & { href: string; level?: 2 | 3 | 4 };

const { href, level = 2, class: className, ...rest } = Astro.props;
---

<li data-slot="toc-item" data-level={level}>
  <a href={href} aria-current="false" class={className} data-slot="toc-link" {...rest}>
    <slot />
  </a>
</li>
src/components/ui/navigation/table-of-contents/active-index.ts
// src/components/ui/navigation/table-of-contents/active-index.ts — the one decision ScrollSpy has to make, in a
// plain module so it is checkable without a browser (see active-index.test.ts).
//
// "Which heading am I reading?" has no answer from an IntersectionObserver alone. The observer knows
// which headings are inside the spy band — a thin strip across the top of the viewport — and that
// set is empty for most of a long section, because the heading scrolled out of the band long before
// the section ended. Falling back to "nothing is current" there is what makes hand-rolled scroll
// spies flicker their highlight off halfway down every section.
//
// So: the first heading in the band if there is one, and otherwise the LAST heading that has already
// gone past the top of the viewport, which is by definition the section being read.

/**
 * Index of the heading to mark current, or `-1` when the reader is above the first one.
 *
 * Both arrays are in document order and the same length; `visible[i]` is whether heading `i` is in
 * the spy band, `above[i]` whether it has scrolled past the top of the viewport.
 *
 * @example activeIndex([false, false, false], [true, true, false]) // 1 — the second section
 */
export function activeIndex(visible: readonly boolean[], above: readonly boolean[]): number {
  const inBand = visible.indexOf(true);
  return inBand === -1 ? above.lastIndexOf(true) : inBand;
}
src/components/ui/navigation/table-of-contents/index.ts
import ScrollSpy from "./ScrollSpy.astro";
import TableOfContents from "./TableOfContents.astro";
import TocItem from "./TocItem.astro";

export { ScrollSpy, TableOfContents, TocItem };
export default TableOfContents;

What you get

The component source, copied into your project by npx astrocraft-ui add navigation/table-of-contents — no package dependency, no CSS to fight. Whatever the paid blocks compose, this is it.