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, shown without its comments; the files you install keep them, with the reasoning behind every rule.

src/components/ui/navigation/table-of-contents/ScrollSpy.astro
---
import type { HTMLAttributes } from "astro/types";

type Props = HTMLAttributes<"div"> & {
  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^="#"]')];
    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)),
        pairs.map((pair) => pair.target.getBoundingClientRect().top <= 1),
      );
      pairs.forEach((pair, j) => {
        pair.link.setAttribute("aria-current", j === i ? "location" : "false");
      });
    };

    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
---
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
---
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
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.