Navigation

Table of contents

An on this page list of a document's headings that marks the section in view and scrolls to the one you pick.

Built on
<nav>IntersectionObserveraria-current="location"scrollend
Client JavaScript
Yes, for state
Works without React
React only (needs JavaScript)
Source, gzipped
3.2 kB
Dependencies
React

Install

Add the package with your package manager and restart the dev server.

Configure

Set the region and the project key in the config file at the root.

Deploy

Push to main. Every other branch gets a preview link of its own.

Code
"use client";

import { useId } from "react";
import { TableOfContents } from "@/components/equip/table-of-contents";

const sections = [
  { slug: "install", label: "Install", text: "Add the package with your package manager and restart the dev server." },
  { slug: "configure", label: "Configure", text: "Set the region and the project key in the config file at the root." },
  { slug: "deploy", label: "Deploy", text: "Push to main. Every other branch gets a preview link of its own." },
];

export default function Demo() {
  const base = useId().replace(/[^\w-]/g, "");
  const id = (slug: string) => `${base}-${slug}`;
  return (
    <div style={{ display: "grid", gridTemplateColumns: "84px 1fr", gap: 8, width: 216, height: 124 }}>
      <TableOfContents items={sections.map((s) => ({ id: id(s.slug), label: s.label }))} title="Setup" />
      <div
        style={{
          overflowY: "auto",
          padding: "0 8px",
          border: "1px solid var(--eq-line)",
          borderRadius: "var(--eq-radius-m)",
          background: "var(--eq-surface)",
          fontSize: "var(--eq-text-xs)",
          color: "var(--eq-ink-2)",
        }}
      >
        {sections.map((s) => (
          <section key={s.slug} style={{ paddingBlock: "6px 24px" }}>
            <h3 id={id(s.slug)} style={{ margin: "0 0 2px", fontSize: "var(--eq-text-s)", color: "var(--eq-ink)" }}>
              {s.label}
            </h3>
            <p style={{ margin: 0 }}>{s.text}</p>
          </section>
        ))}
      </div>
    </div>
  );
}

Examples

Nested headings

Pass the headings flat with their level; deeper ones nest under the heading before them. The section whose heading last crossed the top third is marked.

Overview

Webhooks send an HTTP POST to your endpoint whenever something happens in your project.

Events

Subscribe only to the events you handle, so retries stay cheap.

Deployment events

Sent when a deployment is created, succeeds, fails or is promoted.

Domain events

Sent when a domain is added, verified or its certificate renews.

Security

Every request carries a signature header made with your signing secret.

Verify signatures

Compute the HMAC of the raw body and compare it in constant time.

Retries

Failed deliveries are retried with backoff for up to 24 hours.

Code
"use client";

import { useId } from "react";
import { TableOfContents } from "@/components/equip/table-of-contents";

const outline = [
  { slug: "overview", label: "Overview", level: 2, text: "Webhooks send an HTTP POST to your endpoint whenever something happens in your project." },
  { slug: "events", label: "Events", level: 2, text: "Subscribe only to the events you handle, so retries stay cheap." },
  { slug: "deployment-events", label: "Deployment events", level: 3, text: "Sent when a deployment is created, succeeds, fails or is promoted." },
  { slug: "domain-events", label: "Domain events", level: 3, text: "Sent when a domain is added, verified or its certificate renews." },
  { slug: "security", label: "Security", level: 2, text: "Every request carries a signature header made with your signing secret." },
  { slug: "verify-signatures", label: "Verify signatures", level: 3, text: "Compute the HMAC of the raw body and compare it in constant time." },
  { slug: "retries", label: "Retries", level: 2, text: "Failed deliveries are retried with backoff for up to 24 hours." },
];

export default function Example() {
  const base = useId().replace(/[^\w-]/g, "");
  const id = (slug: string) => `${base}-${slug}`;
  return (
    <div style={{ display: "flex", flexWrap: "wrap-reverse", gap: 16, width: "100%" }}>
      <div
        style={{
          flex: "1 1 240px",
          height: 280,
          overflowY: "auto",
          padding: "0 16px",
          border: "1px solid var(--eq-line)",
          borderRadius: "var(--eq-radius-l)",
          background: "var(--eq-surface)",
          fontSize: "var(--eq-text-s)",
          lineHeight: 1.6,
          color: "var(--eq-ink-2)",
        }}
      >
        {outline.map((s) => {
          const Heading = s.level === 2 ? "h3" : "h4";
          return (
            <section key={s.slug} style={{ paddingBlock: "12px 48px" }}>
              <Heading id={id(s.slug)} style={{ margin: "0 0 4px", color: "var(--eq-ink)" }}>
                {s.label}
              </Heading>
              <p style={{ margin: 0 }}>{s.text}</p>
            </section>
          );
        })}
      </div>
      <TableOfContents
        style={{ flex: "0 1 180px" }}
        updateHash={false}
        items={outline.map((s) => ({ id: id(s.slug), label: s.label, level: s.level }))}
      />
    </div>
  );
}

Under a sticky header

offset keeps headings clear of a sticky bar when you jump to them, and moves the line a heading must cross by the same amount.

Checkout outage, 14 March

Summary

Payments failed for 23 minutes after a certificate on the card gateway expired.

Timeline

Payments failed for 23 minutes after a certificate on the card gateway expired.

Root cause

Payments failed for 23 minutes after a certificate on the card gateway expired.

Follow-up actions

Payments failed for 23 minutes after a certificate on the card gateway expired.

Code
"use client";

import { useId } from "react";
import { TableOfContents } from "@/components/equip/table-of-contents";

const parts = [
  { slug: "summary", label: "Summary" },
  { slug: "timeline", label: "Timeline" },
  { slug: "root-cause", label: "Root cause" },
  { slug: "follow-up", label: "Follow-up actions" },
];

export default function Example() {
  const base = useId().replace(/[^\w-]/g, "");
  const id = (slug: string) => `${base}-${slug}`;
  return (
    <div style={{ display: "grid", gridTemplateColumns: "minmax(0, 1fr)", gap: 16, width: "100%", maxWidth: 480 }}>
      <TableOfContents
        title="Incident report"
        offset={44}
        updateHash={false}
        items={parts.map((p) => ({ id: id(p.slug), label: p.label }))}
      />
      <div
        style={{
          height: 220,
          overflowY: "auto",
          border: "1px solid var(--eq-line)",
          borderRadius: "var(--eq-radius-l)",
          background: "var(--eq-surface)",
          fontSize: "var(--eq-text-s)",
          lineHeight: 1.6,
          color: "var(--eq-ink-2)",
        }}
      >
        <div
          style={{
            position: "sticky",
            top: 0,
            height: 44,
            display: "flex",
            alignItems: "center",
            padding: "0 16px",
            borderBottom: "1px solid var(--eq-line)",
            background: "var(--eq-raised)",
            color: "var(--eq-ink)",
            fontWeight: "var(--eq-weight-strong)",
          }}
        >
          Checkout outage, 14 March
        </div>
        {parts.map((p) => (
          <section key={p.slug} style={{ padding: "12px 16px 56px" }}>
            <h3 id={id(p.slug)} style={{ margin: "0 0 4px", color: "var(--eq-ink)" }}>
              {p.label}
            </h3>
            <p style={{ margin: 0 }}>Payments failed for 23 minutes after a certificate on the card gateway expired.</p>
          </section>
        ))}
      </div>
    </div>
  );
}

Props

PropTypeDefaultWhat it does
items{ id: string; label: ReactNode; level?: number }[]noneThe headings in document order, by id. level defaults to 2; deeper levels nest under the heading before them.
titleReactNode"On this page"The visible title, which also names the landmark.
offsetnumber0Pixels kept clear at the top of the scroll area, for a sticky header. Applies to jumping and to tracking.
updateHashbooleantrueWrites the heading's id into the address bar when a link is followed, without adding a history entry.
aria-labelstringnoneNames the landmark when the title is not text, or is not the name you want read.
...restComponentProps<"nav">noneEvery native nav attribute.

Anatomy

root.eq-table-of-contentsA nav landmark named by the title.
title.eq-table-of-contents-title
list.eq-table-of-contents-listAn ordered list; data-depth counts from 0. The top one draws the track line.
item.eq-table-of-contents-item
link.eq-table-of-contents-linkThe section in view has aria-current="location"; its ::before is the bar on the track.

Keyboard

TabMoves through the links in order.
EnterScrolls to the heading and moves focus to it.

Notes

  • It watches the headings with an IntersectionObserver rooted on their nearest scrolling ancestor, so it works for a page or a scroll box.
  • A section counts as in view once its heading crosses the top third of the scroll area, and stays so until the next heading crosses.
  • Following a link moves focus to the heading as well as the scroll, so the next Tab continues in that section; a heading without a tab stop gets tabindex="-1".
  • Scrolling is smooth unless the motion tokens are at zero, which is where reduced motion puts them.
  • Every link is a real fragment link, so it works before JavaScript loads and with a modifier key opens in a new tab.
  • The current link is marked with aria-current="location" and not by colour alone; it is also bolder and carries a bar.

Source

Two files, and they are the whole part. Open either to read it, copy it straight from its bar, or add the part to your loadout and install it with everything else you picked.

components/equip/table-of-contents.tsx180 lines
"use client";

import { useEffect, useId, useRef, useState, type ComponentProps, type MouseEvent, type ReactNode } from "react";
import "./table-of-contents.css";

export type TocHeading = {
  /** The heading element's id. */
  id: string;
  label: ReactNode;
  /** Heading level, 2 for h2 and so on. Deeper levels nest under the one before. */
  level?: number;
};

export type TableOfContentsProps = Omit<ComponentProps<"nav">, "title"> & {
  /** The headings on the page, in document order. */
  items: TocHeading[];
  /** The visible title, which also names the landmark. */
  title?: ReactNode;
  /** Pixels between the top of the scroll area and a heading that has been scrolled to, for a sticky header. */
  offset?: number;
  /** Writes the heading's id into the address bar when a link is followed. */
  updateHash?: boolean;
};

type TocNode = TocHeading & { children: TocNode[] };

/** Turns a flat list with levels into a tree: each item nests under the nearest shallower one before it. */
function nest(items: TocHeading[]): TocNode[] {
  const root: TocNode[] = [];
  const stack: { level: number; children: TocNode[] }[] = [{ level: -Infinity, children: root }];
  for (const item of items) {
    const level = item.level ?? 2;
    while (stack.length > 1 && stack[stack.length - 1].level >= level) stack.pop();
    const node: TocNode = { ...item, children: [] };
    stack[stack.length - 1].children.push(node);
    stack.push({ level, children: node.children });
  }
  return root;
}

/** The nearest ancestor that scrolls vertically, or null for the page itself. */
function scrollParent(element: HTMLElement): HTMLElement | null {
  for (let node = element.parentElement; node && node !== document.body; node = node.parentElement) {
    const overflow = getComputedStyle(node).overflowY;
    if (overflow === "auto" || overflow === "scroll" || overflow === "overlay") return node;
  }
  return null;
}

/** Smooth unless the motion tokens are at zero, which is where reduced motion puts them. */
function scrollBehavior(element: Element): ScrollBehavior {
  const value = getComputedStyle(element).getPropertyValue("--eq-dur-move").trim();
  return parseFloat(value) === 0 ? "auto" : "smooth";
}

/** A heading counts as reached once its top passes this share of the scroll area. */
const LINE = 0.3;

export function TableOfContents({
  items,
  title = "On this page",
  offset = 0,
  updateHash = true,
  className,
  "aria-label": ariaLabel,
  ...rest
}: TableOfContentsProps) {
  const titleId = useId();
  const [active, setActive] = useState<string | null>(null);
  const lock = useRef<string | null>(null);
  const ids = items.map((item) => item.id).join(" ");

  useEffect(() => {
    const headings = ids
      .split(" ")
      .map((id) => document.getElementById(id))
      .filter((element): element is HTMLElement => element !== null);
    if (headings.length === 0) return;
    const box = scrollParent(headings[0]);

    function pick() {
      if (lock.current) return;
      const top = box ? box.getBoundingClientRect().top : 0;
      const height = box ? box.clientHeight : window.innerHeight;
      const line = top + offset + (height - offset) * LINE;
      // At the very end the last sections may never reach the line; the last one in view wins then.
      const scroller = box ?? document.scrollingElement;
      const atEnd = scroller ? scroller.scrollTop > 0 && scroller.scrollTop + scroller.clientHeight >= scroller.scrollHeight - 2 : false;
      const reach = atEnd ? top + height : line;
      let current = headings[0].id;
      for (const heading of headings) {
        if (heading.getBoundingClientRect().top <= reach) current = heading.id;
        else break;
      }
      setActive(current);
    }

    // The observer's band ends at the line, so it reports exactly when a heading crosses it.
    const observer = new IntersectionObserver(pick, {
      root: box,
      rootMargin: `${-offset}px 0px ${-(1 - LINE) * 100}% 0px`,
      threshold: [0, 1],
    });
    for (const heading of headings) observer.observe(heading);
    // Reaching the end crosses no heading, so the end of each scroll is checked too.
    const source: HTMLElement | Window = box ?? window;
    source.addEventListener("scrollend", pick);
    pick();
    return () => {
      observer.disconnect();
      source.removeEventListener("scrollend", pick);
    };
  }, [ids, offset]);

  function follow(event: MouseEvent<HTMLAnchorElement>, id: string) {
    const heading = document.getElementById(id);
    if (!heading || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
    event.preventDefault();
    const box = scrollParent(heading);
    const behavior = scrollBehavior(event.currentTarget);
    if (box) {
      const top = box.scrollTop + heading.getBoundingClientRect().top - box.getBoundingClientRect().top - offset;
      box.scrollTo({ top, behavior });
    } else {
      window.scrollTo({ top: window.scrollY + heading.getBoundingClientRect().top - offset, behavior });
    }

    // Hold the highlight on the chosen heading until the scroll settles.
    setActive(id);
    lock.current = id;
    const source: HTMLElement | Window = box ?? window;
    // Released a moment after the scroll ends, so late observer reports from the jump are ignored.
    const release = () => {
      source.removeEventListener("scrollend", settle);
      if (lock.current === id) lock.current = null;
    };
    const settle = () => window.setTimeout(release, 150);
    source.addEventListener("scrollend", settle);
    window.setTimeout(release, 1500);

    // Focus follows, so the next Tab continues from the section and screen readers read it.
    if (!heading.hasAttribute("tabindex")) heading.setAttribute("tabindex", "-1");
    heading.focus({ preventScroll: true });
    if (updateHash) history.replaceState(history.state, "", `#${id}`);
  }

  function list(nodes: TocNode[], depth: number): ReactNode {
    return (
      <ol className="eq-table-of-contents-list" data-depth={depth}>
        {nodes.map((node) => (
          <li key={node.id} className="eq-table-of-contents-item">
            <a
              className="eq-table-of-contents-link"
              href={`#${node.id}`}
              aria-current={active === node.id ? "location" : undefined}
              onClick={(event) => follow(event, node.id)}
            >
              {node.label}
            </a>
            {node.children.length > 0 && list(node.children, depth + 1)}
          </li>
        ))}
      </ol>
    );
  }

  return (
    <nav
      {...rest}
      aria-label={ariaLabel}
      aria-labelledby={ariaLabel ? undefined : titleId}
      className={["eq-table-of-contents", className].filter(Boolean).join(" ")}
    >
      <p id={titleId} className="eq-table-of-contents-title">
        {title}
      </p>
      {list(nest(items), 0)}
    </nav>
  );
}
components/equip/table-of-contents.css87 lines
@layer equip {
  .eq-table-of-contents {
    min-width: 0;
    font: var(--eq-weight) var(--eq-text-s) / 1.35 var(--eq-font);
  }

  .eq-table-of-contents-title {
    margin: 0 0 var(--eq-space-2);
    color: var(--eq-ink);
    font-weight: var(--eq-weight-strong);
  }

  /* The top list draws the track; the current link lays a bar over it. */
  .eq-table-of-contents-list {
    display: flex;
    flex-direction: column;
    margin: 0;
    padding: 0;
    list-style: none;
  }

  .eq-table-of-contents-list[data-depth="0"] {
    border-inline-start: 1px solid var(--eq-line);
  }

  .eq-table-of-contents-item {
    min-width: 0;
  }

  .eq-table-of-contents-link {
    --_indent: var(--eq-space-3);

    position: relative;
    display: block;
    padding: 4px var(--eq-space-2) 4px var(--_indent);
    border-radius: 0 var(--eq-radius-s) var(--eq-radius-s) 0;
    color: var(--eq-ink-2);
    text-decoration: none;
    overflow-wrap: anywhere;
    transition: color var(--eq-dur) var(--eq-ease);
  }

  .eq-table-of-contents-link:dir(rtl) {
    border-radius: var(--eq-radius-s) 0 0 var(--eq-radius-s);
  }

  .eq-table-of-contents-list[data-depth="1"] .eq-table-of-contents-link {
    --_indent: calc(var(--eq-space-3) * 2);
  }

  .eq-table-of-contents-list[data-depth="2"] .eq-table-of-contents-link {
    --_indent: calc(var(--eq-space-3) * 3);
    font-size: var(--eq-text-xs);
  }

  .eq-table-of-contents-link::before {
    content: "";
    position: absolute;
    inset-block: 3px;
    inset-inline-start: -1px;
    width: 2px;
    border-radius: 1px;
    background: var(--eq-ink);
    scale: 1 0;
    transition: scale var(--eq-dur-move) var(--eq-spring);
  }

  .eq-table-of-contents-link[aria-current] {
    color: var(--eq-ink);
    font-weight: var(--eq-weight-strong);
  }

  .eq-table-of-contents-link[aria-current]::before {
    scale: 1 1;
  }

  @media (hover: hover) {
    .eq-table-of-contents-link:hover {
      color: var(--eq-ink);
    }
  }

  .eq-table-of-contents-link:focus-visible {
    outline: 2px solid var(--eq-focus);
    outline-offset: -2px;
  }
}

EqUIp is in beta: free for non-commercial use, no warranty, and things can change. Terms

Nothing picked yet. Press A on a part, or use the plus on the board, and the install command builds here.

Loading the index.

Loading the index