Only headings inside this element are enhanced. DocLayout puts the class on
the article that wraps the MDX body.
Heading Anchors
Every h2–h6 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.
## Deploy to Vercelrehype-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:
https://example.com/features/platform/example#deploy-to-vercelThe 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.
## Deploy to Vercel
### Environment variables
#### ACCESS_MODEThe 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:
<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:
.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-articleheading 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 idslugGenerated from the heading text by rehype-slug. It becomes the URL fragment
and the button's data-heading-id value.
data-stateattributeDefault: idleCopy status on the button: idle, copied, or failed. Resets to idle
1800 ms after a click.
data-failure-reasonattributeWritten only when the clipboard write rejects. Holds the error name and
message, or clipboard-api-unavailable.
data-url-update-failedattributeSet to 1 when history.replaceState throws, so the copy still succeeds even
though the address bar didn't move.
data-anchor-enhancedattributeMarks a heading the component has already processed. Prevents duplicate buttons on remount.
doc-anchor-headingclassAdded to every enhanced heading. Sets position: relative so the button
positions against the heading.
doc-anchor-copyclassThe button itself. Hidden at opacity: 0 until the heading is hovered or
something inside it takes focus.
aria-labelstringDefault: Copy link to sectionFlips 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: 1800Milliseconds the copied and failed states hold before the button returns
to idle. A repeat click restarts the timer.