Ghost 블로그에 플로팅 목차(TOC) 만들기: 스크롤 스파이와 부드러운 애니메이션

Ghost 블로그에 플로팅 목차를 추가하는 방법을 단계별로 설명합니다. 테마 수정 없이 Code Injection만으로 스크롤 스파이 기능이 포함된 사이드바 목차를 구현할 수 있습니다.

1. 문제 상황

블로그 글이 길어지면서 독자들이 원하는 섹션을 찾기 어려워졌습니다.

필요한 기능

  • 고정 목차: 스크롤해도 화면 옆에 항상 표시
  • 스크롤 스파이: 현재 읽고 있는 섹션 자동 하이라이트
  • 부드러운 이동: 목차 클릭 시 해당 섹션으로 스무스 스크롤
  • 깔끔한 디자인: 본문을 방해하지 않는 미니멀한 스타일

제약 조건

  • Ghost Source 테마 사용 중
  • 테마 파일 직접 수정 없이 Code Injection만으로 구현

2. 해결 방법 개요

Ghost의 Code Injection 기능을 활용하면 테마 수정 없이 커스텀 기능을 추가할 수 있습니다.

구성 요소 역할 위치
CSS 목차 스타일링, 고정 위치 Site Header
JavaScript 목차 생성, 스크롤 스파이 Site Footer

작동 원리:

  1. 페이지 로드 시 본문의 h2 태그를 파싱
  2. 동적으로 목차 HTML 생성
  3. 스크롤 이벤트로 현재 위치 추적
  4. 클릭 시 해당 섹션으로 부드럽게 이동

3. CSS 구현

Ghost Admin → Settings → Code injection → Site Header에 추가합니다.

기본 컨테이너

<style>
.floating-toc {
  position: fixed;
  top: 120px;
  left: calc(50% + 420px);
  width: 260px;
  max-height: calc(100vh - 180px);
  overflow-y: auto;
  font-size: 13px;
  display: none;
}
</style>

각 속성의 역할:

속성 설명
position: fixed - 스크롤해도 화면에 고정
top 120px 상단 여백 (헤더 높이 고려)
left calc(50% + 420px) 본문 우측에 배치
width 260px 목차 너비
max-height calc(100vh - 180px) 긴 목차 스크롤 허용
display: none - 기본 숨김 (반응형 처리)

반응형 처리

@media (min-width: 1500px) {
  .floating-toc { display: block; }
}

화면 너비가 1500px 이상일 때만 목차를 표시합니다. 좁은 화면에서는 본문과 겹치므로 숨깁니다.

팁: 본문 너비에 따라 left 값과 미디어 쿼리 브레이크포인트를 조정하세요.

카드 스타일 디자인

.floating-toc {
  background: #fff;
  border-radius: 12px;
  padding: 20px;
  box-shadow: 0 4px 24px rgba(0,0,0,0.08);
}

살짝 띄운 카드 느낌으로 본문과 시각적으로 분리합니다.

제목 스타일

.toc-title {
  font-size: 12px;
  font-weight: 600;
  color: #666;
  margin-bottom: 14px;
}

목록 스타일

.floating-toc ul {
  list-style: none;
  padding: 0;
  margin: 0;
}

.floating-toc li {
  margin: 2px 0;
}

링크 스타일

.floating-toc a {
  display: block;
  padding: 8px 12px;
  color: #666;
  text-decoration: none;
  border-radius: 6px;
  transition: all 0.15s;
}

.floating-toc a:hover {
  background: #f5f5f5;
  color: #333;
}

호버 시 배경색이 살짝 변하면서 클릭 가능함을 알려줍니다.

활성 상태 스타일

.floating-toc a.active {
  background: var(--ghost-accent-color, #4f46e5);
  color: #fff;
}

현재 읽고 있는 섹션은 Ghost 테마의 액센트 컬러로 강조합니다. var(--ghost-accent-color)를 사용하면 Ghost Admin에서 설정한 브랜드 컬러가 자동 적용됩니다.

전체 CSS 코드

<style>
.floating-toc {
  position: fixed;
  top: 120px;
  left: calc(50% + 420px);
  width: 260px;
  max-height: calc(100vh - 180px);
  overflow-y: auto;
  font-size: 13px;
  display: none;
  background: #fff;
  border-radius: 12px;
  padding: 20px;
  box-shadow: 0 4px 24px rgba(0,0,0,0.08);
}

@media (min-width: 1500px) {
  .floating-toc { display: block; }
}

.toc-title {
  font-size: 12px;
  font-weight: 600;
  color: #666;
  margin-bottom: 14px;
}

.floating-toc ul {
  list-style: none;
  padding: 0;
  margin: 0;
}

.floating-toc li { margin: 2px 0; }

.floating-toc a {
  display: block;
  padding: 8px 12px;
  color: #666;
  text-decoration: none;
  border-radius: 6px;
  transition: all 0.15s;
}

.floating-toc a:hover {
  background: #f5f5f5;
  color: #333;
}

.floating-toc a.active {
  background: var(--ghost-accent-color, #4f46e5);
  color: #fff;
}
</style>

4. JavaScript 구현

Ghost Admin → Settings → Code injection → Site Footer에 추가합니다.

기본 구조

document.addEventListener('DOMContentLoaded', function() {
  // 본문 영역 찾기
  const article = document.querySelector('.gh-content');
  if (!article) return;

  // h2 헤딩만 선택
  const headings = article.querySelectorAll('h2');
  if (headings.length < 2) return;  // ← 목차가 의미 있으려면 최소 2개

  // ... 목차 생성 및 기능 추가
});

왜 h2만 선택하나요?

h3까지 포함하면 목차가 너무 길어져서 오히려 가독성이 떨어집니다. h2 수준의 주요 섹션만 표시하는 것이 깔끔합니다.

목차 HTML 생성

// TOC 컨테이너 생성
const toc = document.createElement('aside');
toc.className = 'floating-toc';
toc.innerHTML = '<nav><p class="toc-title">목차</p><ul></ul></nav>';
const tocList = toc.querySelector('ul');

// 각 헤딩을 목차 항목으로 변환
headings.forEach((heading, i) => {
  // 헤딩에 id가 없으면 생성
  if (!heading.id) heading.id = `heading-${i}`;

  // 목차 항목 생성
  const li = document.createElement('li');
  li.innerHTML = `<a href="#${heading.id}">${heading.textContent}</a>`;
  tocList.appendChild(li);
});

// body에 추가
document.body.appendChild(toc);

Ghost는 기본적으로 헤딩에 id를 부여하지 않으므로, 직접 생성해야 합니다.

부드러운 스크롤 구현

toc.querySelectorAll('a').forEach(link => {
  link.addEventListener('click', function(e) {
    e.preventDefault();  // ← 기본 앵커 동작 방지

    const targetId = this.getAttribute('href').substring(1);
    const target = document.getElementById(targetId);

    if (target) {
      // 헤더 높이(80px)를 고려한 위치 계산
      const top = target.getBoundingClientRect().top + window.pageYOffset - 80;
      window.scrollTo({ top: top, behavior: 'smooth' });
    }
  });
});

scrollIntoView 대신 window.scrollTo를 사용하나요?

일부 Ghost 테마에서 scrollIntoView가 제대로 작동하지 않는 경우가 있습니다. window.scrollTo가 더 안정적입니다.

오프셋 조정:
- 80 부분은 고정 헤더 높이입니다. 테마에 따라 조정하세요.

스크롤 스파이 구현

const tocLinks = toc.querySelectorAll('a');

function updateActiveLink() {
  let current = null;
  const offset = 150;  // ← 활성화 기준점 (뷰포트 상단에서 150px)

  // 현재 화면에 보이는 헤딩 찾기
  headings.forEach(heading => {
    const rect = heading.getBoundingClientRect();
    if (rect.top <= offset) {
      current = heading;
    }
  });

  // 모든 링크에서 active 클래스 제거
  tocLinks.forEach(link => link.classList.remove('active'));

  // 현재 섹션 링크에 active 클래스 추가
  if (current) {
    const activeLink = toc.querySelector(`a[href="#${current.id}"]`);
    activeLink?.classList.add('active');
  }
}

// 스크롤 이벤트 리스너 등록
window.addEventListener('scroll', updateActiveLink, { passive: true });

// 페이지 로드 시 초기 상태 설정
updateActiveLink();

왜 IntersectionObserver 대신 scroll 이벤트를 사용하나요?

IntersectionObserver는 요소가 뷰포트에 들어오고 나갈 때만 콜백이 실행됩니다. 빠르게 스크롤하면 중간 섹션을 건너뛰는 문제가 발생할 수 있습니다.

scroll 이벤트 기반 방식은:

  • 스크롤할 때마다 모든 헤딩 위치를 확인
  • 현재 위치에 가장 가까운 헤딩을 정확히 찾음
  • 즉각적인 반응으로 사용자 경험 향상

{ passive: true } 옵션은 스크롤 성능을 최적화합니다.

전체 JavaScript 코드

<script>
document.addEventListener('DOMContentLoaded', function() {
  const article = document.querySelector('.gh-content');
  if (!article) return;

  const headings = article.querySelectorAll('h2');
  if (headings.length < 2) return;

  // TOC 컨테이너 생성
  const toc = document.createElement('aside');
  toc.className = 'floating-toc';
  toc.innerHTML = '<nav><p class="toc-title">목차</p><ul></ul></nav>';
  const tocList = toc.querySelector('ul');

  headings.forEach((heading, i) => {
    if (!heading.id) heading.id = `heading-${i}`;
    const li = document.createElement('li');
    li.innerHTML = `<a href="#${heading.id}">${heading.textContent}</a>`;
    tocList.appendChild(li);
  });

  document.body.appendChild(toc);

  // 부드러운 스크롤
  toc.querySelectorAll('a').forEach(link => {
    link.addEventListener('click', function(e) {
      e.preventDefault();
      const targetId = this.getAttribute('href').substring(1);
      const target = document.getElementById(targetId);
      if (target) {
        const top = target.getBoundingClientRect().top + window.pageYOffset - 80;
        window.scrollTo({ top: top, behavior: 'smooth' });
      }
    });
  });

  // 스크롤 스파이
  const tocLinks = toc.querySelectorAll('a');

  function updateActiveLink() {
    let current = null;
    const offset = 150;

    headings.forEach(heading => {
      const rect = heading.getBoundingClientRect();
      if (rect.top <= offset) {
        current = heading;
      }
    });

    tocLinks.forEach(link => link.classList.remove('active'));
    if (current) {
      const activeLink = toc.querySelector(`a[href="#${current.id}"]`);
      if (activeLink) activeLink.classList.add('active');
    }
  }

  window.addEventListener('scroll', updateActiveLink, { passive: true });
  updateActiveLink();
});
</script>

5. 커스터마이징 가이드

위치 조정

/* 본문과의 간격 조정 */
.floating-toc {
  left: calc(50% + 420px);  /* 값을 늘리면 오른쪽으로 이동 */
  top: 120px;               /* 값을 늘리면 아래로 이동 */
}

h3 포함하기

목차에 h3도 표시하고 싶다면:

// JavaScript 수정
const headings = article.querySelectorAll('h2, h3');

// 목차 항목 생성 시 클래스 추가
headings.forEach((heading, i) => {
  if (!heading.id) heading.id = `heading-${i}`;
  const li = document.createElement('li');
  li.className = heading.tagName.toLowerCase();  // ← h2 또는 h3
  li.innerHTML = `<a href="#${heading.id}">${heading.textContent}</a>`;
  tocList.appendChild(li);
});
/* CSS에 h3 들여쓰기 추가 */
.floating-toc li.h3 {
  margin-left: 12px;
}

다크 모드 지원

@media (prefers-color-scheme: dark) {
  .floating-toc {
    background: #1a1a1a;
    box-shadow: 0 4px 24px rgba(0,0,0,0.3);
  }

  .toc-title { color: #999; }

  .floating-toc a { color: #999; }
  .floating-toc a:hover {
    background: #2a2a2a;
    color: #fff;
  }
}

미니멀 스타일로 변경

카드 스타일 대신 더 가벼운 디자인을 원한다면:

.floating-toc {
  background: transparent;
  box-shadow: none;
  padding: 0;
}

.toc-title {
  font-size: 11px;
  text-transform: uppercase;
  letter-spacing: 0.05em;
  color: #999;
}

.floating-toc a {
  padding: 8px 0;
  border-radius: 0;
  color: #aaa;
}

.floating-toc a:hover {
  background: transparent;
  color: #333;
}

.floating-toc a.active {
  background: transparent;
  color: #000;
  font-weight: 500;
}

6. 트러블슈팅

목차가 표시되지 않음

원인 1: 화면 너비 부족

/* 브레이크포인트를 낮춰서 테스트 */
@media (min-width: 1200px) {
  .floating-toc { display: block; }
}

원인 2: 헤딩이 2개 미만

// 조건 수정
if (headings.length < 1) return;  // 1개부터 표시

원인 3: 본문 선택자 불일치
테마에 따라 본문 클래스가 다를 수 있습니다:

// 테마별 선택자 확인
const article = document.querySelector('.gh-content')
  || document.querySelector('.post-content')
  || document.querySelector('article');

스크롤 스파이가 부정확함

오프셋 값 조정:

const offset = 150;  // 값을 조정해서 최적의 타이밍 찾기

고정 헤더가 큰 테마라면 오프셋을 늘리세요.

클릭해도 이동하지 않음

헤더 높이 조정:

const top = target.getBoundingClientRect().top + window.pageYOffset - 80;
//                                                                   ↑ 테마 헤더 높이

브라우저 개발자 도구에서 헤더 높이를 확인하고 값을 조정하세요.

목차가 본문과 겹침

left 값 조정:

.floating-toc {
  left: calc(50% + 450px);  /* 더 오른쪽으로 */
}

또는 본문 너비에 따라 동적으로 계산:

const contentWidth = article.offsetWidth;
const contentLeft = article.getBoundingClientRect().left;
toc.style.left = `${contentLeft + contentWidth + 40}px`;

7. 핵심 개념 정리

개념 설명 사용 기술
플로팅 스크롤해도 화면에 고정 position: fixed
스크롤 스파이 현재 위치 자동 추적 scroll 이벤트 + getBoundingClientRect
스무스 스크롤 부드러운 화면 이동 window.scrollTo({ behavior: 'smooth' })
반응형 화면 크기별 표시/숨김 CSS 미디어 쿼리
동적 생성 본문 헤딩 기반 목차 자동 생성 DOM API

8. 베스트 프랙티스

작성 시 체크리스트

  • [ ] h2로 명확한 섹션 구분하기
  • [ ] 섹션 제목은 간결하게 (목차에서 잘리지 않도록)
  • [ ] 글 당 h2는 5-10개가 적당
  • [ ] 너무 긴 제목은 피하기

성능 최적화

  • [ ] { passive: true } 옵션으로 스크롤 성능 확보
  • [ ] 헤딩 개수 체크로 불필요한 목차 생성 방지
  • [ ] CSS transition은 짧게 (0.15s 권장)

접근성

  • [ ] 시맨틱 태그 사용 (<aside>, <nav>)
  • [ ] 충분한 색상 대비
  • [ ] 클릭 영역 충분히 크게 (최소 44px)

9. FAQ

Q: Ghost 기본 기능으로 TOC를 만들 수 없나요?
A: Ghost는 기본 TOC 기능을 제공하지 않습니다. Code Injection이나 테마 수정이 필요합니다.

Q: 테마를 업데이트하면 사라지나요?
A: Code Injection은 테마와 별개로 저장되므로 테마 업데이트 후에도 유지됩니다.

Q: 특정 페이지에서만 목차를 숨기고 싶어요.
A: JavaScript에서 URL 체크를 추가하세요:

if (window.location.pathname === '/about/') return;

Q: 목차 클릭 시 URL에 해시가 추가되게 하려면?
A: e.preventDefault() 다음에 history.pushState를 추가하세요:

history.pushState(null, null, '#' + targetId);

Q: 모바일에서도 목차를 보여주고 싶어요.
A: 햄버거 메뉴나 하단 시트 형태로 별도 구현이 필요합니다. 플로팅 사이드바는 모바일에 적합하지 않습니다.


10. 참고 자료