jQuery / Plugin / jquery.toc / H 태그로 목차 만드는 플러그인 - 클릭 시 부드럽게 이동

jquery.toc는 선택한 영역 안의 h1~h6 제목을 찾아 링크가 있는 목차를 만드는 jQuery 플러그인입니다. 본문에 .toc({ container: "#contents" })를 호출하면 지정한 별도 영역에 목차를 넣고, 항목 클릭 시 해당 제목으로 부드럽게 이동합니다. 본문과 목차 영역을 분리하고 제목 단계와 재생성 방식을 정하는 것이 중요합니다.

사용할 소스와 제목 구조 준비하기

이 글은 jakobbossek의 jquery.toc를 다룹니다. 이름이 비슷한 다른 TOC 플러그인과 옵션을 섞지 않습니다. 확인한 v1.1 소스의 커밋을 고정했으며, 아래의 jquery.toc.js 원본 파일을 내려받아 HTML 옆에 같은 이름으로 저장합니다. 저장소 화면 HTML이 아니라 JavaScript 원문을 저장해야 합니다.

전체 HTML과 jquery.toc.js 두 파일이 같은 폴더에 있어야 합니다. jQuery 전체 파일 4.0.0은 CDN에서 읽습니다. 로딩 순서는 jQuery → jquery.toc.js → 초기화입니다. 스크롤 애니메이션에 effects 기능을 쓰므로 slim은 피합니다. Windows Edge에서 같은 플러그인 소스를 jQuery 4.0.0과 3.7.1 각각으로 검증했습니다.

플러그인은 선택한 본문 안의 :header를 모두 수집합니다. 예제는 #article만 선택하고, 목차와 문서 제목·aside는 밖에 둡니다. h2 다음에 h3 두 개를 배치해 실제 병렬 하위 주제를 표현했습니다. 원하는 모양을 만들려고 제목 단계를 임의로 건너뛰지 않습니다. 이 소스에는 최대 깊이를 정하는 옵션이 구현되어 있지 않습니다.

범위·포커스·재생성을 포함한 전체 예제

다음을 UTF-8의 toc-demo.html로 저장합니다. 별도 서버나 빌드 없이 열 수 있고, CDN을 읽을 인터넷 연결이 필요합니다. 제목은 직접 관리하는 단순 텍스트로 구성했습니다. 원본 플러그인은 제목 HTML을 다시 감싸고 문자를 HTML 문자열에 넣으므로, 입력받은 임의의 제목이나 제목 안의 버튼·링크를 그대로 처리하는 범용 예제로 보지 않습니다.

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Generate a scoped table of contents</title>
  <script src="https://code.jquery.com/jquery-4.0.0.min.js"></script>
  <script src="jquery.toc.js"></script>
  <style>
    html { scroll-behavior: auto; }
    body { font: 16px/1.6 sans-serif; max-width: 760px; margin: 20px; }
    #contents { border: 1px solid teal; padding: 16px; }
    #contents ul { padding-left: 24px; }
    #contents a { overflow-wrap: anywhere; }
    #article p { min-height: 440px; }
    h2:focus, h3:focus { outline: 3px solid teal; }
    button { font: inherit; }
    footer { min-height: 100vh; }
  </style>
</head>
<body>
  <h1>A focused table of contents</h1>
  <nav id="contents" aria-label="Article contents"><p>Contents will be generated when scripts load.</p></nav>
  <button type="button" id="rebuild">Rebuild original example</button>
  <p id="status" role="status">Article text is available below.</p>
  <main id="article">
    <h2 id="existing-overview">Overview</h2><p>First main topic with an existing stable ID.</p>
    <h3>Preparation</h3><p>First subtopic.</p>
    <h3>Verification</h3><p>Second subtopic.</p>
    <h2>Overview</h2><p>A different section with the same displayed title.</p>
  </main>
  <aside><h2>Outside the article</h2><p>This heading must not enter the contents.</p></aside>
  <footer>Space for the final destination.</footer>
  <script>
    jQuery(function ($) {
      if (typeof $.fn.toc !== "function") return;
      const $article = $("#article"), $toc = $("#contents"), original = $article.html();
      const $scroller = $(document.scrollingElement);
      let generation = 0;
      function build() {
        generation++; $scroller.stop(true, false);
        $article.html(original); $toc.empty().off("click.demo");
        $article.toc({ container: "#contents", strip: false, stripAfter: 30, scrollSpeed: 400, scrollOffset: 20 });
        // Replace this example's generated links' click handlers only.
        $toc.find("a").off("click");
        $toc.on("click.demo", "a[href^='#hl_']", function (event) {
          const anchor = document.getElementById(this.hash.slice(1));
          if (!anchor) return;
          event.preventDefault();
          const run = ++generation, heading = anchor.parentElement;
          $scroller.stop(true, false);
          const reduce = matchMedia("(prefers-reduced-motion: reduce)").matches;
          $scroller.animate({ scrollTop: Math.max(0, $(heading).offset().top - 20) }, reduce ? 0 : 400, function () {
            if (run !== generation) return;
            heading.tabIndex = -1; heading.focus({ preventScroll: true });
            $("#status").text("Arrived: " + heading.textContent);
          });
        });
        $("#status").text("Entries: " + $toc.find("a").length);
      }
      $("#rebuild").on("click", function () { build(); window.scrollTo(0, 0); });
      build();
    });
  </script>
</body>
</html>

초기화 결과는 다음과 같습니다. 두 Overview는 같은 표시 이름이지만 서로 다른 본문 구역입니다.

Entries: 4
  1. 목차에 Overview, Preparation, Verification, Overview의 네 링크가 있고, Preparation과 Verification이 하위 목록인지 확인합니다. Outside the article은 들어가지 않습니다.
  2. 마지막 Overview를 클릭합니다. 400ms 동안 이동하고 제목 위에 20px이 남으며 포커스 윤곽선이 표시됩니다.
  3. 목차 링크에 키보드로 포커스를 놓고 Enter를 눌러 같은 이동을 확인합니다.
  4. 동작 줄이기 설정이 reduce라면 애니메이션 없이 같은 제목으로 즉시 이동합니다. prefers-reduced-motion은 링크 클릭 시 확인합니다.
  5. Rebuild original example을 여러 번 누릅니다. 맨 위로 돌아오고 Entries: 4가 유지되며 목록과 앵커가 중첩되지 않습니다.
  6. 이동 도중 재생성해도 이전 완료 문구가 새 상태를 덮지 않는지 확인합니다.

이 예제는 목차 생성 후 해당 목차 링크의 원래 클릭 핸들러만 교체합니다. 문서의 실제 스크롤 요소 하나에 애니메이션을 적용하고, 연속 동작의 대기열 정리·취소 구분·제목 포커스·동작 줄이기를 보완한 부분입니다. 원본 플러그인 파일을 수정한 것은 아니며, 직접 관리하는 목차 영역의 핸들러만 대상으로 합니다. URL 해시와 방문 기록은 바꾸지 않습니다.

기본 호출과 옵션의 실제 동작

원본의 기본 동작만 확인하려면 위 HTML의 초기화 script 태그는 유지하고 그 안의 코드를 모두 아래 코드로 교체합니다. 파일 로딩과 본문·#contents는 유지하고 Reset 역할의 버튼·상태 표시는 제거합니다. 단순 호출에서는 원본의 클릭 스크롤을 사용하며 포커스 이동·동작 줄이기·재생성 제어가 포함되지 않습니다.

$("#article").toc({
  container: "#contents",
  strip: false,
  stripAfter: 30,
  scrollSpeed: 400,
  scrollOffset: 20
});

선택자 #article은 어디의 제목을 읽을지, container는 목차를 어디에 넣을지 정합니다. 두 범위를 같은 것으로 혼동하면 원하지 않는 제목까지 들어갈 수 있습니다. wrapWith는 생성 목록을 감싸는 HTML이며 기본 클래스는 toc_container입니다. 스타일은 #contents 안으로 한정해 사이트의 다른 목록과 충돌하지 않게 합니다.

scrollSpeed는 시간 단위 ms입니다. 기본 호출에서 400만 800으로 바꾸어 같은 링크를 비교하고 다시 400으로 되돌릴 수 있습니다. scrollOffset은 제목 내부 앵커의 문서 좌표에서 빼는 값이므로 양수 20은 도착점 위에 여백을 남깁니다. 전체 예제의 보완 핸들러는 제목 박스 자체를 기준으로 20px을 남기므로 원본의 인라인 앵커 좌표와 몇 px 차이가 날 수 있습니다.

전체 예제의 클릭 동작은 보완 핸들러가 담당하므로 .toc()의 scrollSpeed·scrollOffset만 바꾸면 실제 이동이 바뀌지 않습니다. 그 예제에서 시간을 비교하려면 animate의 400을, 여백을 바꾸려면 제목 좌표에서 빼는 20을 수정합니다. 동작 줄이기일 때의 0은 유지합니다.

이 소스에서 줄임 여부를 검사하는 키는 strip입니다. 기본 설정에는 shorten도 보이지만 실제 생성 조건은 settings.strip이므로 shorten:true만 지정해 동작할 것으로 기대하지 않습니다. strip:true이면 모든 제목을 stripAfter 길이에서 substring으로 잘라 ...를 붙이며, 짧은 제목도 ...가 붙습니다. 문자 수는 단어 수가 아니고 이모지 같은 조합 문자를 자를 수 있어, 읽기 쉬운 목차에는 strip:false를 우선합니다.

앵커·기존 id·중복과 적용 시 주의점

확인한 소스는 제목의 기존 id를 덮어쓰는 대신, 제목 안에 hl_112 같은 id를 가진 a를 삽입합니다. 따라서 예제의 existing-overview는 유지됩니다. 생성 id는 제목 문자열이 아니라 선택 영역과 순서·단계에서 만들어지므로 같은 표시 제목도 이 예제에서는 서로 다른 링크가 됩니다. 한글 텍스트도 원문 수집 대상이지만, 제목을 추가·재정렬하면 생성 id가 바뀔 수 있어 영구 링크 규칙으로 삼지 않습니다.

원본은 기존 목차를 지우지 않고 append하고 제목의 HTML도 다시 감쌉니다. 같은 문서에 반복 호출하면 중복 목록·앵커가 생길 수 있습니다. 전체 예제는 처음 저장한 본문 HTML로 되돌리고 목차를 비운 뒤 한 번 생성합니다. 버튼 이름처럼 원래 예제 복원이 목적이므로, 실행 후 본문에 추가한 내용이나 입력 상태·직접 연결한 이벤트는 보존하지 않습니다. 실제 편집기에 그대로 적용할 때는 현재 문서와 안정적인 id를 관리하는 별도 설계가 필요합니다.

기존에 목차를 자동 생성하는 테마나 워드프레스 쇼트코드가 있다면 같은 본문에 이 스크립트까지 중복 초기화하지 않습니다. 이 글의 설명용 예제와 사이트의 공통 목차는 별개입니다. 기본 소스의 링크 핸들러 선택 범위도 .toc 전체에 걸치므로, 여러 목차 인스턴스가 있는 페이지에서 독립 동작을 당연하게 가정하지 않습니다.

목차가 없으면 jquery.toc.js의 실제 경로·내용, 로딩 순서, 본문 안의 제목과 container 존재를 확인합니다. 이동 위치가 다르면 고정 메뉴, 늦게 커지는 이미지·글꼴과 문서 끝의 남은 공간을 점검합니다. 긴 제목은 CSS 줄바꿈으로 처리하고, 목차가 없어도 본문을 읽을 수 있게 유지합니다.