본문으로 건너뛰기
컴포넌트

목차

읽는 위치를 따라가는 목차예요. 옆에 고정하거나 접이식으로 둘 수 있어요.

  • #arc
  • #component
  • #toc
  • #navigation
  • #docs
  • #reading
  • #sticky

설치

이 명령 한 줄이면 필요한 파일과 패키지가 함께 설치돼요.

터미널bash
npx shadcn@latest add https://dsui.vercel.app/r/toc.json

toc.module.csscss
.aside { position: sticky; inset-block-start: var(--space-20); display: flex; flex-direction: column; gap: var(--space-4); align-items: flex-start; }
.title { margin: 0 0 var(--space-3); color: var(--text-secondary); font-size: var(--text-xs); font-weight: var(--label-weight); }
.list { display: flex; margin: 0; padding: 0; flex-direction: column; list-style: none; border-inline-start: 2px solid var(--border); }
/* The rule runs down the whole list and the active link paints its own slice of it, so the mark sits on one line. */
.link { display: block; border-inline-start: 2px solid transparent; margin-inline-start: -2px; padding: var(--space-2) var(--space-3); color: var(--text-secondary); font-size: var(--text-sm); line-height: 1.5; text-decoration: none; word-break: keep-all; transition: color var(--duration-fast) var(--ease-standard), border-color var(--duration-fast) var(--ease-standard); }
.link[data-level="3"] { padding-inline-start: var(--space-6); }
.link[data-level="4"] { padding-inline-start: var(--space-10); }
.link[aria-current="true"] { border-inline-start-color: var(--accent); color: var(--accent); font-weight: 500; }
@media (hover: hover) and (pointer: fine) { .link:hover { color: var(--foreground); } .link[aria-current="true"]:hover { color: var(--accent); } }

.toTop { display: inline-flex; min-height: var(--control-height-sm); align-items: center; gap: var(--space-2); border: 0; border-radius: var(--radius-pill); padding: 0 var(--space-3); background: var(--surface-muted); color: var(--foreground); font: inherit; font-size: var(--text-sm); font-weight: var(--label-weight); cursor: pointer; transition: background var(--duration-fast) var(--ease-standard); }
.toTop svg { flex: none; }
@media (hover: hover) and (pointer: fine) { .toTop:hover { background: var(--surface-sunken); } }
.toTop:active { background: var(--surface-sunken); }

.inline { border: 1px solid var(--border-subtle); border-radius: var(--radius-panel); background: var(--surface); overflow: hidden; }
.toggle { display: flex; width: 100%; min-height: var(--control-height-md); align-items: center; gap: var(--space-2); border: 0; padding: 0 var(--space-4); background: transparent; color: var(--foreground); font: inherit; font-size: var(--text-sm); font-weight: var(--label-weight); text-align: start; cursor: pointer; }
.toggleLabel { flex: 1 1 auto; min-width: 0; }
.count { flex: none; color: var(--text-secondary); font-size: var(--text-xs); font-variant-numeric: tabular-nums; }
.chevron { flex: none; color: var(--text-secondary); transition: transform var(--duration-fast) var(--ease-standard); }
.toggle[aria-expanded="true"] .chevron { transform: rotate(90deg); }
.inline .list { border: 0; border-top: 1px solid var(--border-subtle); padding: var(--space-2) var(--space-4) var(--space-3); }
.inline .link { border-inline-start: 0; margin-inline-start: 0; padding-inline: 0; }
.inline .link[data-level="3"] { padding-inline-start: var(--space-4); }
.inline .link[data-level="4"] { padding-inline-start: var(--space-8); }

.srOnly { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
@media (prefers-reduced-motion: reduce) { .link, .toTop, .chevron { transition: none; } }
toc.tsxtsx
"use client";

import { useEffect, useId, useState } from "react";
import { playUiSound } from "../sound/play";
import { UiIcon } from "../theme/ui-icon";
import styles from "./toc.module.css";
import { prefersReducedMotion } from "../lib/use-reduced-motion";

export interface TocEntry {
  id: string;
  text: string;
  /** Heading depth; 2 and 3 are indented apart. */
  level: number;
}

/**
 * The heading the reader is inside: the last one whose top has passed just under the sticky header. Reading position
 * is not the same as "what is on screen", which is why this is not an IntersectionObserver — a long section would
 * leave nothing marked once its heading scrolled away.
 */
function useActiveHeading(entries: TocEntry[], offset: number): string | undefined {
  const [active, setActive] = useState<string | undefined>(undefined);
  useEffect(() => {
    if (entries.length === 0 || typeof window === "undefined") return;
    let frame = 0;
    const update = () => {
      frame = 0;
      let current: string | undefined;
      for (const entry of entries) {
        const el = document.getElementById(entry.id);
        if (el && el.getBoundingClientRect().top <= offset) current = entry.id;
      }
      // At the very bottom the last heading may never reach the top, so mark it once the page cannot scroll further.
      if (window.innerHeight + window.scrollY >= document.documentElement.scrollHeight - 2) current = entries[entries.length - 1].id;
      setActive(current);
    };
    const onScroll = () => { if (!frame) frame = requestAnimationFrame(update); };
    frame = requestAnimationFrame(update);
    window.addEventListener("scroll", onScroll, { passive: true });
    window.addEventListener("resize", onScroll);
    return () => {
      window.removeEventListener("scroll", onScroll);
      window.removeEventListener("resize", onScroll);
      if (frame) cancelAnimationFrame(frame);
    };
  }, [entries, offset]);
  return active;
}

export interface BackToTopProps {
  label?: string;
  /** The id focus moves to, so a keyboard reader lands at the start of the content and not on a button that scrolled away. */
  focusId?: string;
  className?: string;
}

/** Goes back to the top and moves focus with it. */
export function BackToTop({ label = "맨 위로", focusId = "content", className }: BackToTopProps) {
  return (
    <button
      type="button"
      className={[styles.toTop, className].filter(Boolean).join(" ")}
      onClick={() => {
        playUiSound("tick");
        const reduced = prefersReducedMotion();
        window.scrollTo({ top: 0, behavior: reduced ? "auto" : "smooth" });
        document.getElementById(focusId)?.focus({ preventScroll: true });
      }}
    >
      <UiIcon iconRole="arrow-up" size={16} strokeWidth={1.75} aria-hidden="true" />
      {label}
    </button>
  );
}

function TocList({ entries, active, onPick }: { entries: TocEntry[]; active?: string; onPick?: () => void }) {
  return (
    <ol className={styles.list}>
      {entries.map(entry => (
        <li key={entry.id}>
          <a
            href={`#${entry.id}`}
            className={styles.link}
            data-level={entry.level}
            aria-current={active === entry.id ? "true" : undefined}
            onClick={() => { playUiSound("tick"); onPick?.(); }}
          >
            {entry.text}
          </a>
        </li>
      ))}
    </ol>
  );
}

export interface TocProps {
  entries: TocEntry[];
  title?: string;
  /** How far under the top of the window a heading counts as reached. Match your sticky header's height. */
  offset?: number;
  /** Adds "맨 위로" under the list. Only for the `aside` shape. */
  backToTop?: boolean;
  className?: string;
}

/** The sticky "이 페이지에서" column beside a document, with the reading position marked. */
export function TocAside({ entries, title = "이 페이지에서", offset = 140, backToTop = true, className }: TocProps) {
  const active = useActiveHeading(entries, offset);
  const titleId = useId();
  if (entries.length === 0) return null;
  return (
    <aside aria-labelledby={titleId} data-print="hide" className={[styles.aside, className].filter(Boolean).join(" ")}>
      <nav aria-labelledby={titleId}>
        <p id={titleId} className={styles.title}>{title}</p>
        <TocList entries={entries} active={active} />
      </nav>
      {backToTop ? <BackToTop /> : null}
    </aside>
  );
}

/** The same list folded into a disclosure, for when there is no room for a column. */
export function TocInline({ entries, title = "이 페이지에서", className }: TocProps) {
  const [open, setOpen] = useState(false);
  const listId = useId();
  if (entries.length === 0) return null;
  return (
    <nav aria-label={title} data-print="hide" className={[styles.inline, className].filter(Boolean).join(" ")}>
      <button
        type="button"
        className={styles.toggle}
        aria-expanded={open}
        aria-controls={listId}
        onClick={() => { playUiSound("tick"); setOpen(v => !v); }}
      >
        <UiIcon iconRole="chevron-right" size={14} strokeWidth={1.75} aria-hidden="true" className={styles.chevron} />
        <span className={styles.toggleLabel}>{title}</span>
        <span className={styles.count}>{entries.length}<span className={styles.srOnly}>개 단락</span></span>
      </button>
      <div id={listId} hidden={!open}>
        <TocList entries={entries} onPick={() => setOpen(false)} />
      </div>
    </nav>
  );
}

export interface TocVariantProps extends TocProps {
  /** `aside` is the sticky column beside the text; `inline` folds the same list into a disclosure. */
  variant?: "aside" | "inline";
}

/**
 * The table of contents in either shape. A page usually renders both — the column for wide screens and the
 * disclosure for narrow ones — and hides whichever does not fit, so the reader always has one.
 */
export function Toc({ variant = "aside", ...props }: TocVariantProps) {
  return variant === "inline" ? <TocInline {...props} /> : <TocAside {...props} />;
}

Toc.displayName = "Toc";
TocAside.displayName = "TocAside";
TocInline.displayName = "TocInline";
BackToTop.displayName = "BackToTop";

export default Toc;