HeadingAnchor
A section number in a heading's gutter that becomes a # and copies the link.
The idea
One slot doing two jobs, because a heading only has one margin. At rest it shows the section number; pointed at, it becomes a # that copies the deep link and confirms with a check. It is a real link, so it survives copy link address and shows itself on keyboard focus rather than being an invisible tab stop — and the URL changes even when the clipboard refuses, since the address bar is the fallback.
Install
npm i mihirmodi-ui motionThe 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
import { HeadingAnchor } from "mihirmodi-ui";
// The heading carries the group and the positioning context; the anchor
// hangs in its left margin. Leave it out where there is no margin.
<h2 id="annual-offer" className="group/heading relative scroll-mt-28">
<HeadingAnchor id="annual-offer" label="The annual offer" index="02" />
The annual offer
</h2>Props
| Prop | Type | Notes |
|---|---|---|
| idstring | string | The heading's own id — what the link points at. |
| labelstring | string | The heading's text, for the name a screen reader hears. |
| indexstring | string | A section number like "04", shown at rest. |
| classNamestring | string= "absolute -left-10 top-[5px]" | Placement in the gutter. The heading needs `group/heading relative`. |
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.
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { CheckIcon } from "../lib/glyph.js";
export type HeadingAnchorProps = {
/** The heading's id — what the link points at. */
id: string;
/** The heading's text, for the name a screen reader hears. */
label: string;
/** A section number like "04", shown at rest on pages that number their sections. */
index?: string;
/** Placement in the heading's gutter. The default hangs it 40px left of the heading. */
className?: string;
};
const CONFIRM_MS = 1600;
/**
* The gutter mark on a heading: which section this is, and a way to link to it.
*
* One slot doing two jobs, because a heading only has one margin. At rest it
* shows the section number, or nothing; pointed at, it becomes a `#` that copies
* the deep link and confirms with a check. The two never show at once, so the
* gutter stays one mark wide however it is used.
*
* It is a real `<a href="#id">`, so it works with no JavaScript, survives
* "copy link address", and shows itself on keyboard focus rather than being an
* invisible tab stop. The URL changes even if the clipboard refuses, since the
* address bar is the fallback — and no check appears for a copy that failed.
*
* The heading needs `group/heading relative`, and a margin for the mark to hang
* in; below that width, leave the anchor out rather than cover the text.
*/
export function HeadingAnchor({ id, label, index, className = "absolute -left-10 top-[5px]" }: HeadingAnchorProps) {
const [copied, setCopied] = useState(false);
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => () => void (timer.current && clearTimeout(timer.current)), []);
const copy = useCallback(
(e: React.MouseEvent) => {
e.preventDefault();
window.history.replaceState(null, "", `#${id}`);
navigator.clipboard?.writeText(`${location.origin}${location.pathname}#${id}`).then(
() => {
setCopied(true);
if (timer.current) clearTimeout(timer.current);
timer.current = setTimeout(() => setCopied(false), CONFIRM_MS);
},
() => {}
);
},
[id]
);
return (
<span className={`block font-mono text-[11px] font-normal leading-none text-faint ${className}`}>
{index && (
<span
aria-hidden
className={`transition-opacity duration-150 ease-geist group-hover/heading:opacity-0 group-focus-within/heading:opacity-0 ${copied ? "opacity-0" : ""}`}
>
{index}
</span>
)}
<a
href={`#${id}`}
onClick={copy}
aria-label={copied ? `Link to ${label} copied` : `Copy link to ${label}`}
// Pulled out and padded back in: the glyph stays on the number's
// baseline while the target is 27px square.
className={`absolute -left-2 -top-2 p-2 leading-none transition-opacity duration-150 ease-geist hover:text-gray-1000 focus-visible:opacity-100 group-hover/heading:opacity-100 motion-reduce:transition-none ${
copied ? "text-gray-1000 opacity-100" : "opacity-0"
}`}
>
{copied ? <CheckIcon className="h-3 w-3" /> : "#"}
</a>
</span>
);
}