Skip to main content

Heading Anchors

4 min readStableBeginner

Every h2h6 heading inside a doc article gets a hover-revealed copy button. Click it and the deep link to that section lands on your clipboard, while the address bar fragment updates without a navigation. There's no MDX tag to write — components/HeadingAnchors.tsx mounts inside the article element and enhances the headings your Markdown already produced.

Quick Start

Write an ordinary Markdown heading. That's the whole setup.

app/features/platform/example.mdx
MDX
## Deploy to Vercel

rehype-slug turns the heading text into the id deploy-to-vercel at build time, and the copy button fades in when you hover the heading or tab to it. The headings on this page are the live example — hover the "Quick Start" line above and the button appears to the right of the text.

Clicking it copies one absolute URL:

Copied to the clipboard
Plain Text
https://example.com/features/platform/example#deploy-to-vercel

The button builds that string from window.location.origin, window.location.pathname, and the heading id, then calls history.replaceState so the fragment shows up in the address bar without reloading the page.

Adding Options

Nest deeper headings and each level gets its own button, all the way down to h6.

app/features/platform/example.mdx
MDX
## Deploy to Vercel
 
### Environment variables
 
#### ACCESS_MODE

The component queries h2[id], h3[id], h4[id], h5[id], h6[id] inside the .doc-article element, so the page h1 stays untouched, any heading without an id is skipped, and headings in the sidebar or the table of contents are out of scope. A second affordance sits on the same heading: rehype-autolink-headings runs with behavior: 'prepend' and puts a link element in front of the text. They don't overlap. The prepended link jumps, the appended button copies.

Each enhanced heading carries the marker attributes the component wrote:

Rendered heading markup
HTML
<h3
  id="environment-variables"
  class="doc-anchor-heading"
  data-anchor-enhanced="true"
>
  Environment variables
  <button
    type="button"
    class="doc-anchor-copy"
    aria-label="Copy link to section"
    data-state="idle"
    data-heading-id="environment-variables"
  ></button>
</h3>

data-anchor-enhanced is the guard that keeps a remount from appending a second button to the same heading. The button element itself is built in JavaScript with an inline SVG link icon and type="button", so it is a real control that keyboard users can tab to, not a styled span.

Advanced

Restyle the button, or read why a copy failed, from the attributes it leaves on the DOM.

After a successful copy the button flips data-state to copied and its aria-label to "Link copied", then resets both to idle and "Copy link to section" 1800 ms later. Click the same button again before that window closes and the pending reset is cancelled and restarted, so the label never flips back early. When the Clipboard API rejects — an iframe without clipboard permission, for example — the state goes to failed instead and data-failure-reason records the error name and message, or clipboard-api-unavailable when the browser exposes no navigator.clipboard.writeText at all. A later successful copy on the same button deletes data-failure-reason.

The click handler calls preventDefault() before it touches the clipboard, so the click never bubbles into a navigation. On unmount the component removes every listener, clears every pending timer, and deletes the buttons it added — a route change leaves no orphaned chrome behind.

Style each state under the .doc-anchor-copy selector in app/globals.css rather than editing the component:

app/globals.css
Plain Text
.doc-anchor-copy {
  opacity: 0;
  color: var(--color-text-tertiary);
}
 
.doc-anchor-heading:hover .doc-anchor-copy,
.doc-anchor-heading:focus-within .doc-anchor-copy,
.doc-anchor-copy:focus-visible {
  opacity: 1;
}
 
.doc-anchor-copy[data-state="copied"] {
  opacity: 1;
  color: var(--color-accent);
  background-color: var(--color-accent-muted);
}
 
.doc-anchor-copy[data-state="failed"] {
  opacity: 1;
  color: #ef4444;
}

Options

article scopeselectorDefault: .doc-article

Only headings inside this element are enhanced. DocLayout puts the class on the article that wraps the MDX body.

heading selectorselectorDefault: h2[id], h3[id], h4[id], h5[id], h6[id]

Which headings get a button. The h1 and any heading without an id are left alone.

heading idslug

Generated from the heading text by rehype-slug. It becomes the URL fragment and the button's data-heading-id value.

data-stateattributeDefault: idle

Copy status on the button: idle, copied, or failed. Resets to idle 1800 ms after a click.

data-failure-reasonattribute

Written only when the clipboard write rejects. Holds the error name and message, or clipboard-api-unavailable.

data-url-update-failedattribute

Set to 1 when history.replaceState throws, so the copy still succeeds even though the address bar didn't move.

data-anchor-enhancedattribute

Marks a heading the component has already processed. Prevents duplicate buttons on remount.

doc-anchor-headingclass

Added to every enhanced heading. Sets position: relative so the button positions against the heading.

doc-anchor-copyclass

The button itself. Hidden at opacity: 0 until the heading is hovered or something inside it takes focus.

aria-labelstringDefault: Copy link to section

Flips to Link copied on success and back after the reset delay. A failed copy keeps the idle label so the button still reads as an action.

reset delaynumberDefault: 1800

Milliseconds the copied and failed states hold before the button returns to idle. A repeat click restarts the timer.

Was this page helpful?