컴포넌트
제목 앵커
제목 옆에 숨어 있다가 호버하면 나타나는 링크예요. 누르면 주소를 복사해요.
- #arc
- #component
- #anchor
- #heading
- #link
- #copy
- #docs
1280px
설치
이 명령 한 줄이면 필요한 파일과 패키지가 함께 설치돼요.
터미널터미널터미널bash
npx shadcn@latest add https://dsui.vercel.app/r/heading-anchor.json파일
components/arc/heading-anchor/heading-anchor.module.csscomponents/arc/heading-anchor/heading-anchor.tsx
함께 오는 항목
heading-anchor.module.cssheading-anchor.module.cssheading-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.tsxheading-anchor.tsxheading-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;