Anchor Heading
A heading that owns its anchor — a stable id de-duplicated against the live document, a permanently focusable link that fades in on hover, and one click that deep-links and copies the URL.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/anchor-heading.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "AnchorHeading" component (lucide-react
icons, a cn() class-merge helper; no other dependencies).
Contract
- Named export `AnchorHeading`, forwardRef<HTMLHeadingElement>, props extend
React.HTMLAttributes<HTMLHeadingElement> minus `children`/`id`:
- `as?: "h1".."h6"` — default "h2". The SEMANTIC level.
- `size?: "h1".."h6" | "inherit"` — default: follows `as`. The VISUAL scale.
- `id?: string` — explicit id; skips slug derivation, still de-duplicated.
- `slugify?: (text: string) => string` — default export `slugifyHeading`.
- `writeText?: (value: string) => Promise<void>` — clipboard seam.
- `resetDelay?: number` (2000) — how long the feedback badge stays up.
- `onCopied?: (url: string) => void`, `onCopyError?: (error: unknown) => void`.
- Also export the pure `slugifyHeading(text)` so build-time tooling can produce
the same ids the component would.
- Slots for external targeting: `data-slot="anchor-heading"` on the heading,
`data-slot="anchor-heading-link"` on the anchor (plus `data-state`
idle/copied/error), `data-slot="anchor-heading-status"` on the live region.
Why `as` and `size` are two props, not one
- The level is an accessibility contract: screen-reader users navigate a page by
jumping between headings, and skipping h2 -> h4 tells them a section is missing.
"How big is it" is a layout decision — a sidebar h2 and a hero h2 are the same
level and nothing alike. Folding them into one prop forces every visual choice
to rewrite the document outline. `size="inherit"` emits no typography classes
at all, which is the right setting inside `prose`/MDX where the theme already
styles headings.
Id derivation
- Flatten `children` to text (walk elements' `props.children`, so
`Install <code>zyeon ui</code> now` slugs as one string), then:
NFC-normalise -> lowercase -> whitespace runs to "-" -> drop Unicode
punctuation/symbols/format characters (`/(?![-_])[\p{P}\p{S}\p{C}]/gu`) ->
collapse "--" -> trim leading/trailing "-".
- NON-ASCII IS KEPT VERBATIM. This is the decision that matters most and the one
every naive slugger gets wrong: `[^a-z0-9]+` erases a Chinese, Japanese,
Korean, Greek, Cyrillic or Thai heading down to the EMPTY STRING, at which
point every heading on the page has the same (empty) id and no deep link
works at all. Keeping the characters costs nothing: the href is written as
`#${encodeURIComponent(id)}`, and every browser percent-decodes a fragment
before matching it against `getElementById`. Measured: `安装指南` keeps its id
and its `#%E5%AE%89%E8%A3%85%E6%8C%87%E5%8D%97` href resolves back to that
exact heading.
- Transliteration (pinyin/romaji/ASCII folding) is deliberately NOT built in. It
needs a dictionary, it is lossy, and two different headings frequently
romanise to the same string. It belongs to the content, so it is offered as
the `slugify` prop instead of imposed on everyone.
- Never emit an empty id: a heading that slugs to nothing ("!!!", an emoji, an
em dash) falls back to "section", and the de-duplication below then keeps
several of them apart.
De-duplication (the part that decides whether deep links work at all)
- Two identical headings on one page must NOT share an id, or `#overview` always
lands on the first and the second is permanently unlinkable.
- Use the DOM as the registry, in an effect: walk `base`, `base-2`, `base-3`…
until `document.getElementById(candidate)` is either empty or this very node,
cap the walk (100) so a pathological page cannot spin. The DOM is the right
registry because it is document-scoped, shared by every instance, and it is
literally what a `#fragment` resolves against — so an id already taken by some
unrelated `<div id="taken">` is stepped over too (measured: that heading
becomes `taken-2`).
- Do NOT use a module-level counter. On the server it is shared between
requests, so the second visitor to the same page gets `-2` ids and hydration
mismatches.
- Assign `node.id = candidate` DIRECTLY, before the state update. Sibling
effects all run in one flush BEFORE any of the resulting re-renders land, so
the third duplicate would otherwise still see `base-2` as free and collide
with the second one. Verified by deleting only that line: three identical
headings came out as overview / overview-2 / overview-2 — the first two still
correct, so a two-heading test would have passed.
- Key the resolved id by the slug it came from, so editing the heading text
re-derives instead of freezing on the first id.
- A deep link to a de-duplicated heading (`#overview-2`) is resolved by the
browser BEFORE that id exists, so the browser silently gives up. Only in that
case (candidate !== base), and only once per mount, compare the decoded
`location.hash` with the resolved id and `scrollIntoView()`. Guard the decode
with try/catch — a hand-edited hash can be malformed percent-encoding.
- Honest limit to document: without JavaScript only the first of a set of
identical headings is addressable, because de-duplication needs a document to
inspect. If that matters, slug at build time (rehype-slug does the same job
over the whole document) and pass `id` explicitly.
Anchor and reveal
- The anchor is a REAL `<a href={"#" + encodeURIComponent(id)}>` rendered inside
the heading, present in the DOM and in the tab order AT ALL TIMES. Hide it
with `opacity-0` and reveal with `group-hover:opacity-100` +
`focus-visible:opacity-100` + a permanent reveal on coarse pointers
(`pointer-coarse:opacity-100`, since touch has no hover and an invisible tap
target is no target).
- `opacity: 0` elements are still focusable — that is why the reveal is
opacity-only. `display:none` and `visibility:hidden` remove the element from
the focus order entirely, so a keyboard user could never reach the anchor;
`hidden until :hover` is the single most common way this component is built
wrong.
- Give it an `aria-label` ("Copy link to <heading text>"), a focus-visible ring,
and size it in `em` so it tracks the heading it belongs to.
Click behaviour: do NOT preventDefault
- Let the native fragment navigation run. It is what updates the address bar,
pushes exactly one history entry (so Back returns the reader where they were)
and scrolls the heading into view honouring scroll-margin-top — and all of it
behaves identically with JavaScript disabled. The clipboard write is layered
on top of that, never in place of it.
- Copy `${origin}${pathname}${search}#${encodeURIComponent(id)}` — an absolute
URL, because the point of the button is pasting it somewhere else.
- Three outcomes, three states: resolved -> "copied", rejected -> "error",
clipboard object missing -> ALSO "error". Never report success you did not
have: `navigator.clipboard` is undefined on every insecure origin (plain http
on a LAN IP is the everyday case), and a badge flashing "Copied" while nothing
was copied is worse than one that says it failed. The link itself keeps
working, so "copy link address" from the context menu remains the way out.
- Feedback is a badge next to the heading inside a `role="status"
aria-live="polite"` region, so the announcement and the visible text are the
same string. Restart, do not stack, the reset timer on repeat clicks, and
clear it on unmount.
Sticky headers
- A page with a sticky top bar will scroll the target heading UNDER that bar.
Fix it with `scroll-margin-top`, read from a CSS variable
(`scroll-mt-[var(--anchor-heading-offset,0px)]`) so one declaration on `:root`
or on the article configures every heading at once, and a consumer's own
`scroll-mt-*` still wins through cn()/tailwind-merge. Measured in a scroller
with a 48px sticky bar: without the variable the heading lands 48px above the
bar's bottom edge (fully covered); with it set, at or below it.
Client boundary
- Everything structural here is ordinary markup, and the base feature — a real
anchor that deep-links a heading — needs no JavaScript at all. Only three
things do: the clipboard write, its feedback, and the de-duplication pass.
Keep the module small (one icon set, no state libraries) and let the heading
be a LEAF client component: rendering it from a server component does not turn
the page into a client tree, and `children` handed to it are still rendered on
the server.
- To take the `<hN>` itself out of the client bundle, split the anchor into its
own `"use client"` file and have a server component render
`<h2 id={id} className="group scroll-mt-…">{children}<AnchorLink id={id}/></h2>`,
passing an id you computed at build time with the exported `slugifyHeading`.
That is the smallest possible boundary — but note the DOM de-duplication pass
goes with the client half, which is exactly why build-time ids are the right
partner for that split.
Rendering & styling
- Semantic tokens only: text-muted-foreground / hover:text-foreground for the
anchor, ring-ring for focus, bg-muted + text-muted-foreground for the copied
badge, border-destructive/30 + bg-destructive/10 + text-destructive for the
failed badge. No hex, no oklch, no hardcoded radii.
- `wrap-anywhere` on the heading: a heading can legitimately be a bare URL or a
long compound identifier with no break opportunity, and `break-words` does not
shrink the min-content width. Verified at 375px with an 89-character URL
heading: with the class the page's scrollWidth is 365px, without it 1010px —
635px of sideways scroll.
- The only animation is the opacity transition; `motion-reduce:transition-none`
removes it while hover, focus, copy and navigation all keep working.
- Merge the consumer's className with cn() on the heading — typography, margins
and colour are the page's decisions.
Customization levers
- `--anchor-heading-offset` — set it once on `:root` (or on the article) to your
sticky header's height, e.g. `4rem`. It is the one number that has to match
your layout.
- `size` vs `as` — set `as` from the document structure and `size` from the
design; use `size="inherit"` inside `prose`/MDX so the theme keeps control.
- Pin the anchor open (no hover needed) with
`className="[&_[data-slot=anchor-heading-link]]:opacity-100"`, or move it into
the left gutter with `[&_[data-slot=anchor-heading-link]]:absolute
[&_[data-slot=anchor-heading-link]]:-left-6` — the classic docs look, but
check narrow viewports, a negative offset has nothing to hang off there.
- Swap the icon: `Link2` for `Hash` if you prefer `#`, and the `Check` /
`CircleAlert` feedback pair for anything else in the same set.
- `slugify` — plug in a transliterator (pinyin, romaji, ASCII folding) or the
exact slugger your MDX pipeline already uses, so links stay stable across a
migration. Keeping the same function on both sides is what makes old URLs
survive.
- `writeText` — supply a `document.execCommand("copy")` fallback for insecure
origins, or a stub in tests.
- `onCopied` / `onCopyError` — wire to a toast (sonner) when the inline badge is
too quiet for your layout, or to analytics to see which sections get shared.
- `resetDelay` — raise it if your badge sits somewhere easy to miss.
- Pair with TOC Scrollspy: this component produces the ids, that one consumes
them. Give both the same offset number.Concepts
- Anchor as production, scrollspy as consumption — this component mints the ids a page can be deep-linked by;
toc-scrollspyreads ids that already exist and highlights whichever one you are reading. They are two halves of the same docs page and are meant to share one offset number, not to replace each other. - The DOM is the id registry — uniqueness is a property of a document, so the check runs against the document: walk
base,base-2,base-3… untilgetElementByIdreturns nothing or returns this very node. That also steps around ids owned by things that are not headings, which a component-local counter can never see. A module-level counter is worse than useless: on a server it is shared between requests, so the second visitor gets different ids than the first. - Claim before the siblings look — every sibling's effect runs in the same flush, before any of the resulting re-renders reach the DOM. Writing
node.idimmediately (and only then updating state) is what makes each claim visible to the next effect; removing that one line leaves the first two duplicates correct and the third one colliding — a bug that a two-heading test can never see. - Non-ASCII is kept, not stripped — the reflexive
[^a-z0-9]+slug rule turns安装指南into the empty string, and once every heading has an empty id nothing on the page is linkable. Keeping the characters and percent-encoding only the href costs nothing, because browsers decode a fragment before matching it. Transliteration is a lossy content decision and is left to theslugifyprop. - Transparent, not hidden —
opacity: 0still participates in the focus order;display: noneandvisibility: hiddendo not. Building the reveal out of opacity is the difference between an anchor a keyboard user can Tab to and one that does not exist for them, and it is why the reveal also has to fire on:focus-visibleand on coarse pointers, where there is no hover at all. - Not preventing the default is the feature — the native fragment navigation is what updates the address bar, pushes exactly one history entry and honours
scroll-margin-top. Leaving it alone means the component degrades to precisely itself-without-JavaScript, and the clipboard write is a pure addition rather than a re-implementation. scroll-margin-topas a published variable — a sticky header covers whatever a fragment jump lands on. The fix belongs to the heading, not the scroller, and it is exposed as--anchor-heading-offsetso one declaration configures a whole page while any individualscroll-mt-*still overrides it.- A failed copy says so — clipboard writes are rejected by permissions policy and are simply absent on insecure origins. Both paths land in the same visible "failed" state instead of a "Copied" badge that lies; the anchor is still a link, so the browser's own "copy link address" remains available.
- Level and size are different questions —
aswrites the document outline that screen-reader heading navigation depends on;sizeis typography. Merging them means every visual tweak silently rewrites the outline.
Annotated Text
Prose with notes attached — marked phrases carry a numbered marker, the note opens on hover or focus, and every note is also listed in full under the passage.
External Link
An anchor that decides whether an href leaves your site — SSR-stable, origin declared as a prop — then adds an icon, the noopener patch and a new-window announcement to the ones that do, and nothing at all to the ones that do not.