Skip to content
Component

Heading anchor

A link beside a heading that appears on hover and copies the address.

  • #arc
  • #component
  • #anchor
  • #heading
  • #link
  • #copy
  • #docs

Install

One command installs the files and packages this item needs.

Terminalbash
npx shadcn@latest add https://dsui.vercel.app/r/heading-anchor.json

heading-anchor.module.csscss
/* Hidden by opacity, not display: the link keeps its place in the tab order and its box never shifts the heading. */
.anchor { display: inline-flex; width: 1.25em; height: 1.25em; flex: none; align-items: center; justify-content: center; margin-left: var(--space-2); border-radius: var(--radius-pill); color: var(--text-secondary); opacity: 0; text-decoration: none; vertical-align: middle; transition: opacity var(--duration-fast) var(--ease-standard), color var(--duration-fast) var(--ease-standard); }
.anchor svg { width: 100%; height: 100%; }
/* Shown when the heading is hovered, when the link itself is focused by keyboard, and while it reports a result. */
:where(h1, h2, h3, h4, h5, h6):hover > .anchor,
.anchor:focus-visible,
.anchor[data-state] { opacity: 1; }
@media (hover: hover) and (pointer: fine) { .anchor:hover { color: var(--foreground); } }
/* Touch has no hover, so the link stays visible there rather than never appearing at all. */
@media (hover: none) { .anchor { opacity: .6; } }
.anchor[data-state="copied"] { color: var(--success); }
.anchor[data-state="error"] { color: var(--danger); }
.status { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
@media (prefers-reduced-motion: reduce) { .anchor { transition: none; } }
heading-anchor.tsxtsx
"use client";

import { useCopyFeedback } from "../lib/use-copy-feedback";
import { playUiSound } from "../sound/play";
import { UiIcon } from "../theme/ui-icon";
import styles from "./heading-anchor.module.css";

export interface HeadingAnchorProps {
  /** The heading's id, without the hash. */
  id: string;
  /** The heading's text, used for the link's accessible name. */
  text: string;
  /** What the link copies. Default: this page's URL with the heading's hash. */
  href?: string;
  /** The label read out, with {text} replaced by the heading. */
  label?: string;
  className?: string;
}

/**
 * The link beside a heading that points at that heading. It stays invisible until the heading is hovered or the link
 * itself takes keyboard focus, so a page of headings does not turn into a page of icons — but it is always in the tab
 * order, because a reader who cannot hover still needs it. Pressing it copies the address and says so for a moment.
 *
 * Put it inside the heading, after the text, and give the heading `position: relative` or the shared `.heading` class.
 */
export function HeadingAnchor({ id, text, href, label = "'{text}' 단락 링크", className }: HeadingAnchorProps) {
  const { state, copy } = useCopyFeedback();
  const copied = state === "copied";
  const failed = state === "error";
  return (
    <a
      href={`#${id}`}
      className={[styles.anchor, className].filter(Boolean).join(" ")}
      aria-label={label.replace("{text}", text)}
      data-state={copied ? "copied" : failed ? "error" : undefined}
      onClick={() => {
        playUiSound(copied ? "success" : "click");
        const url = href ?? (typeof window === "undefined" ? `#${id}` : `${window.location.origin}${window.location.pathname}#${id}`);
        void copy(url, id);
      }}
    >
      <UiIcon iconRole={copied ? "check" : failed ? "error" : "link"} size={18} strokeWidth={1.75} aria-hidden="true" />
      {/* The outcome is announced rather than drawn, so the heading's own line never moves. */}
      <span role="status" className={styles.status}>{copied ? "주소를 복사했어요" : failed ? "복사하지 못했어요" : ""}</span>
    </a>
  );
}

HeadingAnchor.displayName = "HeadingAnchor";

export default HeadingAnchor;