Next.js 블로그 만들기 — TOC와 커서 효과로 디테일 살리기
TOC (Table of Contents)
글이 길어지면 목차가 필요하다. 현재 읽고 있는 섹션이 어디인지 한눈에 보여주고, 클릭하면 해당 위치로 이동하는 기능이다.
구현 목표
- 포스트 우측에 고정 배치 (데스크톱만)
- 스크롤에 따라 현재 섹션 하이라이트
- 클릭 시 해당 섹션으로 스무스 스크롤
- 본문의 중앙 정렬에 영향을 주지 않을 것
마지막 항목이 가장 까다로웠다. TOC가 본문을 밀어내면 안 되기 때문이다.
heading에 id 부여하기
TOC가 동작하려면 각 heading에 anchor용 id가 있어야 한다. rehype-slug 플러그인이 이걸 자동으로 해준다.
pnpm add rehype-slugimport rehypeSlug from 'rehype-slug'
// rehype 플러그인에 추가
rehypePlugins: [
rehypeSlug, // heading에 자동으로 id 부여
[rehypePrettyCode, { ... }],
],이제 ## 구현 목표 같은 heading이 <h2 id="구현-목표">구현 목표</h2>로 렌더링된다.
TOC 데이터 추출
마크다운 원본에서 h2, h3 heading을 정규식으로 추출한다:
export interface TocItem {
readonly id: string
readonly text: string
readonly level: number
}
export function extractToc(markdown: string): readonly TocItem[] {
const headingRegex = /^(#{2,3})\s+(.+)$/gm
const items: TocItem[] = []
let match
while ((match = headingRegex.exec(markdown)) !== null) {
const level = match[1].length
const text = match[2].trim()
const id = text
.toLowerCase()
.replace(/[^\w\s가-힣-]/g, '')
.replace(/\s+/g, '-')
items.push({ id, text, level })
}
return items
}h1은 페이지 제목이므로 제외하고, h2와 h3만 추출한다. id 생성 로직은 rehype-slug의 동작과 동일하게 맞춰야 한다.
IntersectionObserver로 현재 섹션 감지
스크롤 시 어떤 heading이 보이는지 감지하려면 두 가지 방법이 있다:
- scroll 이벤트 — throttle 필요, 직접 위치 계산
- IntersectionObserver — 브라우저 최적화, 선언적
IntersectionObserver를 선택했다. rootMargin을 조절해서 heading이 화면 상단 25% 영역에 들어올 때 활성화되도록 했다:
'use client'
import { useEffect, useRef, useState } from 'react'
import type { TocItem } from '@/lib/toc'
export default function TableOfContents({ items }: { items: readonly TocItem[] }) {
const [activeId, setActiveId] = useState('')
const observerRef = useRef<IntersectionObserver | null>(null)
useEffect(() => {
const headings = items
.map((item) => document.getElementById(item.id))
.filter(Boolean) as HTMLElement[]
if (headings.length === 0) return
observerRef.current = new IntersectionObserver(
(entries) => {
const visibleEntries = entries.filter((e) => e.isIntersecting)
if (visibleEntries.length > 0) {
setActiveId(visibleEntries[0].target.id)
}
},
{
// 상단 0% ~ 하단 75% 영역에서 감지
rootMargin: '0px 0px -75% 0px',
threshold: 0,
}
)
headings.forEach((heading) =>
observerRef.current?.observe(heading)
)
return () => observerRef.current?.disconnect()
}, [items])
// ...렌더링 로직
}rootMargin: '0px 0px -75% 0px'의 의미는 "뷰포트 하단 75%를 무시하라"는 뜻이다. 즉, heading이 화면 상단 25% 안에 들어와야 활성화된다. 이렇게 하면 자연스럽게 "지금 읽고 있는 섹션"이 하이라이트된다.
레이아웃: fixed 배치
TOC 레이아웃에서 가장 중요한 원칙은 본문의 중앙 정렬을 해치지 않는 것이다. 처음에는 flex로 배치했다가 본문이 오른쪽으로 밀리는 문제가 있었다.
최종적으로 fixed 포지션을 사용했다:
<nav className="fixed left-[calc(50%+24rem+1rem)]
top-20 hidden w-52 xl:block">left-[calc(50%+24rem+1rem)]— 본문(max-w-3xl = 48rem)의 절반인 24rem + 여백 1remtop-20— navbar(h-14) 아래에 위치hidden xl:block— 1280px 이상에서만 표시
이렇게 하면 TOC가 뷰포트에 고정되어 스크롤해도 항상 보이면서, 본문의 max-w-3xl mx-auto 중앙 정렬에는 전혀 영향을 주지 않는다.
TOC 렌더링
return (
<nav className="fixed left-[calc(50%+24rem+1rem)]
top-20 hidden w-52 xl:block">
<p className="mb-3 text-xs font-semibold uppercase
tracking-wider text-text-tertiary">
On this page
</p>
<ul className="space-y-1 border-l border-border">
{items.map((item) => {
const isActive = activeId === item.id
return (
<li key={item.id}>
<a
href={`#${item.id}`}
onClick={(e) => handleClick(e, item.id)}
className={`block border-l-2 py-1 text-sm
transition-colors
${item.level === 3 ? 'pl-6' : 'pl-3'}
${isActive
? 'border-accent font-medium text-accent'
: 'border-transparent text-text-secondary'
}`}
>
{item.text}
</a>
</li>
)
})}
</ul>
</nav>
)- h2는
pl-3, h3는pl-6으로 들여쓰기해서 계층을 표현 - 활성 항목은 accent 컬러 왼쪽 border + 텍스트 하이라이트
- 클릭 시
window.scrollTo로 스무스 스크롤 (navbar 높이만큼 offset)
삽질: sticky vs fixed
처음에는 sticky를 사용했는데, 두 가지 문제가 있었다:
- absolute 컨테이너 안의 sticky는 동작하지 않는다 — sticky는 가장 가까운 scroll ancestor 기준으로 동작하는데, absolute 안에서는 scroll context가 달라진다.
- 본문 중앙 정렬을 유지하면서 TOC를 배치하기 어렵다 — flex나 grid로 배치하면 TOC가 공간을 차지해서 본문이 밀린다.
결론적으로 fixed가 가장 깔끔한 해법이었다. 뷰포트 기준으로 위치가 고정되니 레이아웃 흐름에 영향을 주지 않는다.
커서 트레일 효과
블로그에 약간의 재미 요소를 추가하고 싶었다. 마우스를 움직이면 accent 컬러의 부드러운 일렁임이 커서를 따라가는 효과를 만들었다.
Canvas + requestAnimationFrame
DOM 요소 대신 Canvas를 사용한 이유는 성능 때문이다. 매 프레임마다 여러 개의 원을 그려야 하는데, DOM 조작은 리플로우가 발생하지만 Canvas는 GPU 가속을 활용할 수 있다.
'use client'
import { useEffect, useRef } from 'react'
const TRAIL_COUNT = 5
const LERP_BASE = 0.15
export default function CursorEffect() {
const canvasRef = useRef<HTMLCanvasElement>(null)
useEffect(() => {
const canvas = canvasRef.current
if (!canvas) return
const ctx = canvas.getContext('2d')
if (!ctx) return
let mouseX = -100
let mouseY = -100
const trails = Array.from({ length: TRAIL_COUNT }, () => ({
x: -100, y: -100,
}))
const handleMouseMove = (e: MouseEvent) => {
mouseX = e.clientX
mouseY = e.clientY
}
const draw = () => {
ctx.clearRect(0, 0, canvas.width, canvas.height)
for (let i = 0; i < TRAIL_COUNT; i++) {
const target = i === 0
? { x: mouseX, y: mouseY }
: trails[i - 1]
// lerp — 뒤쪽 트레일일수록 더 느리게 따라감
const lerp = LERP_BASE / (1 + i * 0.4)
trails[i] = {
x: trails[i].x + (target.x - trails[i].x) * lerp,
y: trails[i].y + (target.y - trails[i].y) * lerp,
}
const radius = 12 + i * 8
const opacity = 0.12 - i * 0.02
const gradient = ctx.createRadialGradient(
trails[i].x, trails[i].y, 0,
trails[i].x, trails[i].y, radius
)
gradient.addColorStop(0, `rgba(18, 184, 134, ${opacity})`)
gradient.addColorStop(1, 'rgba(18, 184, 134, 0)')
ctx.beginPath()
ctx.arc(trails[i].x, trails[i].y, radius, 0, Math.PI * 2)
ctx.fillStyle = gradient
ctx.fill()
}
requestAnimationFrame(draw)
}
// ...이벤트 등록, 리사이즈 핸들러
requestAnimationFrame(draw)
}, [])
return (
<canvas
ref={canvasRef}
className="pointer-events-none fixed inset-0 z-[9999]"
/>
)
}핵심: lerp (Linear Interpolation)
const lerp = LERP_BASE / (1 + i * 0.4)
trails[i].x = trails[i].x + (target.x - trails[i].x) * lerp이 한 줄이 "일렁이는" 느낌의 핵심이다. lerp는 현재 위치와 목표 위치의 차이에 비율을 곱해서 이동시키는 보간 기법이다.
lerp가 클수록 빠르게 따라감- 뒤쪽 트레일(
i가 큰)일수록lerp가 작아져서 더 느리게 따라감 - 결과적으로 5개의 원이 서로 다른 속도로 마우스를 따라가며 일렁이는 효과 생성
pointer-events-none으로 캔버스가 클릭이나 스크롤을 방해하지 않도록 했다.
GNB 최적화: sticky에서 fixed로
블로그를 만들면서 미묘한 UI 이슈들을 발견하고 수정했다. 대표적인 것이 GNB의 스크롤 흔들림이었다.
문제
sticky 포지션 + backdrop-blur를 함께 사용하면, 빠르게 스크롤할 때 GNB가 미세하게 따라오는 현상이 발생했다. will-change: transform이나 translateZ(0) 같은 GPU 승격 트릭을 시도했지만 완전히 해결되지 않았다.
해결
결국 sticky를 fixed로 변경했다:
// Before
<header className="sticky top-0 ...">
// After
<header className="fixed top-0 right-0 left-0 ...">fixed는 뷰포트에 직접 고정되므로 스크롤과 완전히 분리된다. 대신 본문에 mt-14를 추가해서 navbar 높이만큼 밀어줘야 한다.
스크롤바 밀림 방지
또 하나의 이슈가 있었다. 홈 페이지에서 포스트 페이지로 이동하면, 스크롤바 유무에 따라 페이지가 미묘하게 좌우로 밀리는 현상이 있었다. scrollbar-gutter: stable로 해결:
html {
scrollbar-gutter: stable;
}스크롤바 공간을 항상 확보해서, 스크롤바가 있든 없든 레이아웃이 동일하게 유지된다.
GitHub Pages 배포
마지막으로 GitHub Actions를 설정해서 main 브랜치에 push하면 자동으로 배포되도록 했다:
name: Deploy to GitHub Pages
on:
push:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm build
- uses: actions/upload-pages-artifact@v3
with:
path: out
deploy:
needs: build
runs-on: ubuntu-latest
environment:
name: github-pages
steps:
- uses: actions/deploy-pages@v4새 글을 쓰고 git push만 하면 자동으로 빌드되어 https://terajh.github.io/tera-log/ 에 배포된다.
마무리
5편에 걸쳐 블로그를 만들면서 느낀 점은, 사소한 디테일이 전체적인 완성도를 결정한다는 것이다. TOC 레이아웃, GNB 흔들림, 스크롤바 밀림 같은 것들은 눈에 잘 띄지 않지만, 없으면 "뭔가 불편하다"는 느낌을 준다.
앞으로도 기능을 하나씩 추가하면서 이 블로그에 기록해나갈 예정이다.
관심 있을 만한 포스트
Next.js 블로그 만들기 — 스크롤 프로그레스 바와 Canvas 렌더링 이슈 해결
스크롤 진행률 프로그레스 바 구현과 Canvas 커서 효과가 GNB backdrop-blur와 충돌하며 발생한 깜빡임 이슈 해결기.
Next.js 블로그 만들기 — giscus로 댓글 기능 추가
서버 없이 GitHub Discussions 기반 댓글 시스템 giscus를 Next.js 블로그에 연동하기. 다크모드 자동 전환까지.
Next.js 블로그 만들기 — 카드 그리드와 포스트 상세 페이지
Velog 스타일 카드 UI와 MDX 렌더링 상세 페이지 구현. 반응형 그리드, SEO 메타데이터, 정적 사이트 생성까지.
Next.js 블로그 만들기 — 다크모드와 Tailwind CSS v4 테마
Tailwind CSS v4의 CSS-first 설정과 next-themes를 활용한 다크/라이트 모드 구현. 커스텀 프로퍼티 기반 테마 시스템 구축기.
Next.js 15로 개인 블로그 만들기 — 프로젝트 셋업
왜 직접 블로그를 만들었는지, 기술 스택 선정 이유와 프로젝트 초기 구성까지. Next.js 15 + Tailwind CSS v4 + MDX 기반 블로그의 시작.