Only headings inside this element get a copy button. 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.
One click puts the deep link to that section on the clipboard, and the address
bar fragment updates without a navigation. There is no MDX tag to write:
components/HeadingAnchors.tsx mounts inside the article element and adds copy
buttons to the headings Markdown already produced. What follows covers the
markup it writes, the copy states it tracks, and the CSS hooks for restyling it.
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 on hover or keyboard focus. 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. That leaves the page h1 untouched, skips any heading
without an id, and puts the sidebar and the table of contents 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 heading with a button 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 component builds the button in JavaScript with
an inline SVG link icon and type="button", so it is a real control 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. A second click before that window closes cancels the
pending reset and starts it again, so the label never flips back early.
Failures take the other branch. When the Clipboard API rejects, as in an iframe
without clipboard permission, the state goes to failed 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, so
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 heading with a button. 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.