Ghost 블로그에 플로팅 목차(TOC) 만들기: 스크롤 스파이와 부드러운 애니메이션
Ghost 블로그에 플로팅 목차를 추가하는 방법을 단계별로 설명합니다. 테마 수정 없이 Code Injection만으로 스크롤 스파이 기능이 포함된 사이드바 목차를 구현할 수 있습니다.
1. 문제 상황
블로그 글이 길어지면서 독자들이 원하는 섹션을 찾기 어려워졌습니다.
필요한 기능
- 고정 목차: 스크롤해도 화면 옆에 항상 표시
- 스크롤 스파이: 현재 읽고 있는 섹션 자동 하이라이트
- 부드러운 이동: 목차 클릭 시 해당 섹션으로 스무스 스크롤
- 깔끔한 디자인: 본문을 방해하지 않는 미니멀한 스타일
제약 조건
- Ghost Source 테마 사용 중
- 테마 파일 직접 수정 없이 Code Injection만으로 구현
2. 해결 방법 개요
Ghost의 Code Injection 기능을 활용하면 테마 수정 없이 커스텀 기능을 추가할 수 있습니다.
| 구성 요소 | 역할 | 위치 |
|---|---|---|
| CSS | 목차 스타일링, 고정 위치 | Site Header |
| JavaScript | 목차 생성, 스크롤 스파이 | Site Footer |
작동 원리:
- 페이지 로드 시 본문의
h2태그를 파싱 - 동적으로 목차 HTML 생성
- 스크롤 이벤트로 현재 위치 추적
- 클릭 시 해당 섹션으로 부드럽게 이동
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: 햄버거 메뉴나 하단 시트 형태로 별도 구현이 필요합니다. 플로팅 사이드바는 모바일에 적합하지 않습니다.