Text

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { Check, CircleAlert, Link2 } from "lucide-react"
import { cn } from "@/lib/utils"

/** Semantic heading levels. `as` picks the level, `size` picks the look. */
export type AnchorHeadingLevel = "h1" | "h2" | "h3" | "h4" | "h5" | "h6"

/**
 * Visual scale, deliberately separate from `as`. The document outline is an
 * accessibility contract (skipping from h2 to h4 breaks screen-reader heading
 * navigation), while "how big is this" is a layout decision — a sidebar h2 and a
 * hero h2 are the same level and nothing alike. `"inherit"` ships no typography

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/anchor-heading.json

Prompt

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-scrollspy reads 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… until getElementById returns 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.id immediately (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 the slugify prop.
  • Transparent, not hiddenopacity: 0 still participates in the focus order; display: none and visibility: hidden do 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-visible and 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-top as 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-offset so one declaration configures a whole page while any individual scroll-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 questionsas writes the document outline that screen-reader heading navigation depends on; size is typography. Merging them means every visual tweak silently rewrites the outline.

On This Page