← All components

SectionNav

A table of contents that marks your place on a ruler with a blue line that fades in beside the label.

The problem

Monthly customers churned at nearly three times the annual rate, and most of them never saw the annual offer. The fix was never the price; it was where the choice was put in front of people.

Monthly customers churned at nearly three times the annual rate, and most of them never saw the annual offer. The fix was never the price; it was where the choice was put in front of people.

The approach

Monthly customers churned at nearly three times the annual rate, and most of them never saw the annual offer. The fix was never the price; it was where the choice was put in front of people.

Monthly customers churned at nearly three times the annual rate, and most of them never saw the annual offer. The fix was never the price; it was where the choice was put in front of people.

Pricing

Monthly customers churned at nearly three times the annual rate, and most of them never saw the annual offer. The fix was never the price; it was where the choice was put in front of people.

Monthly customers churned at nearly three times the annual rate, and most of them never saw the annual offer. The fix was never the price; it was where the choice was put in front of people.

Rollout

Monthly customers churned at nearly three times the annual rate, and most of them never saw the annual offer. The fix was never the price; it was where the choice was put in front of people.

Monthly customers churned at nearly three times the annual rate, and most of them never saw the annual offer. The fix was never the price; it was where the choice was put in front of people.

The result

Monthly customers churned at nearly three times the annual rate, and most of them never saw the annual offer. The fix was never the price; it was where the choice was put in front of people.

Monthly customers churned at nearly three times the annual rate, and most of them never saw the annual offer. The fix was never the price; it was where the choice was put in front of people.

The idea

Every row draws a tick of the same length over one continuous ruler that starts and ends on a row's own tick; headings are told apart by tone, not by a longer mark. The row you are in fades in a longer blue line and fades its label to blue, and nothing grows or slides — so your place reads as a change of shape and colour together rather than a grey a second grey could be mistaken for. Moving between rows is a crossfade on a slow, symmetric ease — the old row fades out as the next fades in, and they cross in the middle rather than the new one snapping on. Pointing at or focusing a row previews exactly that. Rows are real anchors, so it works with no JavaScript; the handler only takes over to scroll smoothly and keep the URL in step.

Install

terminal1 line
npm i mihirmodi-ui motion

The tokens, the theme script and the routing provider are a one-time job — see Setup. Or skip the package entirely and take the source at the foot of this page.

Usage

SectionNav — usage17 lines
import { SectionNav, SectionNavCompact, useActiveSection, tocIds } from "mihirmodi-ui";

const nodes = [
  { id: "problem", label: "The problem" },
  { id: "approach", label: "The approach", children: [{ id: "pricing", label: "Pricing" }] },
];
const active = useActiveSection(tocIds(nodes));   // pass { root } for a scrolling box

<aside className="sticky top-24 hidden lg:block">
  <SectionNav nodes={nodes} active={active} />
</aside>
<div className="lg:hidden">
  <SectionNavCompact nodes={nodes} active={active} />
</div>

// Give headings a scroll margin to match the reading line.
<h2 id="problem" className="scroll-mt-28">The problem</h2>

Props

PropNotes
nodesTocNode[]`{ id, label, children? }`. A tree with children groups under headings.
activestringThe id being read. Get it from `useActiveSection(tocIds(nodes))`.
onJump(id) => voidScrolls the heading to just under the reading line and updates the hash without a history entry.
useActiveSection(ids, { root?, line? })hookScroll spy. `root` for a list scrolling inside an element; `line` is where a heading counts as reached (default 120).
SectionNavCompactcomponentThe same tree as a one-line disclosure that closes after a jump, for screens with no margin.

Source

Exactly what the package ships — this is read out of the source at build time, not a copy. Take it and adapt it if you would rather not add a dependency.

packages/ui/src/components/SectionNav.tsx212 lines
"use client";

import { useEffect, useState } from "react";
import { ChevronDownIcon } from "../lib/glyph.js";
import { listenScroll, offsetTopIn, scrollHeightOf, scrollToTop, scrollTopOf, viewportHeightOf, type Scroller } from "../lib/scroll.js";

export type TocNode = {
  /** The heading's id — what the row jumps to. */
  id: string;
  label: string;
  children?: TocNode[];
};

/** The tree flattened into document order — what the scroll spy measures. */
export function tocIds(nodes: TocNode[]): string[] {
  return nodes.flatMap((n) => [n.id, ...tocIds(n.children ?? [])]);
}

/**
 * Which of `ids` the reader is in: the last one whose heading has crossed
 * `line` pixels from the top of the scroller. At the very bottom the last id
 * wins, or a short final section could never become active.
 *
 * Measured on scroll and resize behind one rAF. Pass `root` for a section list
 * that scrolls inside an element rather than the page.
 */
export function useActiveSection(ids: string[], { root, line = 120 }: { root?: Scroller; line?: number } = {}) {
  const [active, setActive] = useState(ids[0] ?? "");
  // A fresh array each render; key the effect on the contents.
  const key = ids.join("|");

  useEffect(() => {
    const list = key ? key.split("|") : [];
    if (list.length === 0) return;
    let ticking = false;

    const update = () => {
      ticking = false;
      const y = scrollTopOf(root);
      let current = list[0];
      for (const id of list) {
        const el = document.getElementById(id);
        if (!el) continue;
        if (offsetTopIn(el, root) - y > line) break;
        current = id;
      }
      if (y + viewportHeightOf(root) >= scrollHeightOf(root) - 2) current = list[list.length - 1];
      setActive((prev) => (prev === current ? prev : current));
    };
    const onScroll = () => {
      if (!ticking) {
        ticking = true;
        requestAnimationFrame(update);
      }
    };
    update();
    return listenScroll(root, onScroll);
  }, [key, root, line]);

  return active;
}

/**
 * Scroll a heading to just under the reading line and put its id in the URL,
 * without adding a history entry — a contents jump is a place on this page,
 * not a new page to go Back from.
 */
export function jumpToSection(id: string, { root, line = 120, reduce = false }: { root?: Scroller; line?: number; reduce?: boolean } = {}) {
  const el = document.getElementById(id);
  if (!el) return;
  const smooth = !reduce && !window.matchMedia("(prefers-reduced-motion: reduce)").matches;
  scrollToTop(root, offsetTopIn(el, root) - (line - 16), smooth ? "smooth" : "auto");
  if (!root) window.history.replaceState(null, "", `#${id}`);
}

/**
 * The ruler's pitch. A row is 3px either side of an 18px line and a group gap is
 * four ticks, so every row's own tick lands on one of the ruler's.
 */
const PITCH = 6;

/**
 * How a row lights and unlights. Slower than a hover elsewhere on the site, and
 * symmetric, because the common case is a handover: as the reader scrolls, one
 * row fades out while the next fades in, and on matching in-out curves the two
 * cross in the middle instead of the new one snapping on before the old one
 * has gone. The house curve is front-loaded and overshoots, which is right for
 * a press and wrong for a crossfade.
 */
const GLIDE = "duration-300 ease-[cubic-bezier(0.65,0,0.35,1)]";

/**
 * One row. Its tick is the same length as the ruler's, so the edge reads as one
 * even scale; headings are marked by tone, primary text on both, and items sit a
 * step quieter but still a step brighter than the ticks between rows.
 *
 * Lit — the row you're in, or the one you point at or focus — fades in a longer
 * blue line over the resting one and crossfades the label to blue. It fades
 * rather than grows: a width animation on the house curve overshoots and
 * settles back, which reads as a wobble instead of a change of state.
 */
function Row({ node, heading, current, onJump }: { node: TocNode; heading: boolean; current: boolean; onJump: (id: string) => void }) {
  const label = current ? "text-blue-700" : heading ? "text-gray-1000" : "text-muted";
  return (
    <a
      href={`#${node.id}`}
      aria-current={current ? "true" : undefined}
      onClick={(e) => {
        e.preventDefault();
        onJump(node.id);
      }}
      className={`group relative block py-[3px] pl-9 text-[13px] leading-[18px] transition-colors ${GLIDE} hover:text-blue-700 focus-visible:text-blue-700 motion-reduce:transition-none ${label}`}
    >
      <span aria-hidden className={`absolute left-0 top-3 h-px w-5 ${heading ? "bg-ink" : "bg-gray-700"}`} />
      <span
        aria-hidden
        className={`absolute left-0 top-3 h-px w-[30px] bg-blue-700 transition-opacity ${GLIDE} group-hover:opacity-100 group-focus-visible:opacity-100 motion-reduce:transition-none ${
          current ? "opacity-100" : "opacity-0"
        }`}
      />
      {node.label}
    </a>
  );
}

function Rows({ nodes, active, onJump }: { nodes: TocNode[]; active: string; onJump: (id: string) => void }) {
  const grouped = nodes.some((n) => n.children?.length);
  return (
    <div className="relative">
      {/* One ruler rather than a tick per row, so it keeps its rhythm through
          the gaps between groups. Inset half a row at each end, so the scale
          opens and closes on an entry rather than on two ticks of padding. */}
      <span
        aria-hidden
        style={{ backgroundImage: `repeating-linear-gradient(to bottom, var(--color-gray-500) 0 1px, transparent 1px ${PITCH}px)` }}
        className="absolute bottom-[11px] left-0 top-3 w-5"
      />
      <ul className="relative flex flex-col">
        {nodes.map((n, i) => (
          <li key={n.id} className={grouped && i > 0 ? "mt-6" : undefined}>
            <Row node={n} heading={grouped} current={n.id === active} onJump={onJump} />
            {n.children && (
              <ul className="flex flex-col">
                {n.children.map((c) => (
                  <li key={c.id}>
                    <Row node={c} heading={false} current={c.id === active} onJump={onJump} />
                  </li>
                ))}
              </ul>
            )}
          </li>
        ))}
      </ul>
    </div>
  );
}

export type SectionNavProps = {
  nodes: TocNode[];
  /** The id being read — from `useActiveSection`. */
  active: string;
  /** Defaults to `jumpToSection`. Pass your own to scroll a container or animate differently. */
  onJump?: (id: string) => void;
  label?: string;
};

/**
 * A table of contents that marks your place on a ruler.
 *
 * Every row draws its own tick over one continuous ruler. The row you are in
 * fades in a longer blue line and turns its label blue — a change of shape as
 * well as colour, so the place doesn't rest on a tone of grey that a second one
 * could be mistaken for. Pointing at a row previews exactly that, so hovering
 * tells you what the rail will show once you are there.
 *
 * Rows are real in-page anchors, so they work with no JavaScript and copy as
 * links; the click handler only takes over to scroll smoothly and keep the URL
 * in step. `SectionNavCompact` is the same tree as a one-line disclosure, for a
 * screen with no margin to put a rail in.
 */
export function SectionNav({ nodes, active, onJump = (id) => jumpToSection(id), label = "On this page" }: SectionNavProps) {
  return (
    <nav aria-label={label}>
      <Rows nodes={nodes} active={active} onJump={onJump} />
    </nav>
  );
}

/** The narrow-screen contents: one row above the article, closing itself after a jump. */
export function SectionNavCompact({ nodes, active, onJump = (id) => jumpToSection(id), label = "On this page" }: SectionNavProps) {
  const [open, setOpen] = useState(false);
  return (
    <details open={open} onToggle={(e) => setOpen(e.currentTarget.open)} className="group rounded-xl border-[0.5px] border-gray-alpha-400">
      <summary className="flex cursor-pointer list-none items-center justify-between gap-3 px-3.5 py-2.5 text-[13px] text-gray-1000 [&::-webkit-details-marker]:hidden">
        {label}
        <span className="text-quiet transition-transform duration-200 ease-geist group-open:rotate-180 motion-reduce:transition-none">
          <ChevronDownIcon />
        </span>
      </summary>
      <div className="border-t-[0.5px] border-gray-alpha-400 px-3.5 py-2">
        <Rows
          nodes={nodes}
          active={active}
          onJump={(id) => {
            setOpen(false);
            onJump(id);
          }}
        />
      </div>
    </details>
  );
}
packages/ui/src/lib/scroll.ts46 lines
import type { RefObject } from "react";

/** Something that scrolls: an element, or — when there is no ref — the window. */
export type Scroller = RefObject<HTMLElement | null> | undefined;

export const scrollerEl = (root: Scroller): HTMLElement | null => root?.current ?? null;

export const scrollTopOf = (root: Scroller) => scrollerEl(root)?.scrollTop ?? window.scrollY;

export const viewportHeightOf = (root: Scroller) => scrollerEl(root)?.clientHeight ?? window.innerHeight;

export const scrollHeightOf = (root: Scroller) => scrollerEl(root)?.scrollHeight ?? document.documentElement.scrollHeight;

/**
 * Where an element sits inside the scroller, from the layout box rather than
 * the painted one. `getBoundingClientRect` would be simpler and is wrong
 * whenever something is mid-way through a reveal transform: it reports where the
 * element is drawn, not where it will rest, and every jump lands short.
 *
 * Inside an element scroller, that element must be positioned, so the offset
 * chain stops at it.
 */
export function offsetTopIn(el: HTMLElement, root: Scroller): number {
  const stop = scrollerEl(root);
  let top = 0;
  for (let node: HTMLElement | null = el; node && node !== stop; node = node.offsetParent as HTMLElement | null) {
    top += node.offsetTop;
  }
  return top;
}

export function scrollToTop(root: Scroller, top: number, behavior: ScrollBehavior) {
  const el = scrollerEl(root);
  if (el) el.scrollTo({ top, behavior });
  else window.scrollTo({ top, behavior });
}

export function listenScroll(root: Scroller, fn: () => void) {
  const target: HTMLElement | Window = scrollerEl(root) ?? window;
  target.addEventListener("scroll", fn, { passive: true });
  window.addEventListener("resize", fn);
  return () => {
    target.removeEventListener("scroll", fn);
    window.removeEventListener("resize", fn);
  };
}