Next.js 블로그 만들기 — TOC와 커서 효과로 디테일 살리기

12 min read
TOCCanvasUX

TOC (Table of Contents)

글이 길어지면 목차가 필요하다. 현재 읽고 있는 섹션이 어디인지 한눈에 보여주고, 클릭하면 해당 위치로 이동하는 기능이다.

구현 목표

  1. 포스트 우측에 고정 배치 (데스크톱만)
  2. 스크롤에 따라 현재 섹션 하이라이트
  3. 클릭 시 해당 섹션으로 스무스 스크롤
  4. 본문의 중앙 정렬에 영향을 주지 않을 것

마지막 항목이 가장 까다로웠다. TOC가 본문을 밀어내면 안 되기 때문이다.

heading에 id 부여하기

TOC가 동작하려면 각 heading에 anchor용 id가 있어야 한다. rehype-slug 플러그인이 이걸 자동으로 해준다.

pnpm add rehype-slug
src/lib/mdx.ts
import rehypeSlug from 'rehype-slug'
 
// rehype 플러그인에 추가
rehypePlugins: [
  rehypeSlug,  // heading에 자동으로 id 부여
  [rehypePrettyCode, { ... }],
],

이제 ## 구현 목표 같은 heading이 <h2 id="구현-목표">구현 목표</h2>로 렌더링된다.

TOC 데이터 추출

마크다운 원본에서 h2, h3 heading을 정규식으로 추출한다:

src/lib/toc.ts
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이 보이는지 감지하려면 두 가지 방법이 있다:

  1. scroll 이벤트 — throttle 필요, 직접 위치 계산
  2. IntersectionObserver — 브라우저 최적화, 선언적

IntersectionObserver를 선택했다. rootMargin을 조절해서 heading이 화면 상단 25% 영역에 들어올 때 활성화되도록 했다:

src/components/post/TableOfContents.tsx
'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 + 여백 1rem
  • top-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를 사용했는데, 두 가지 문제가 있었다:

  1. absolute 컨테이너 안의 sticky는 동작하지 않는다 — sticky는 가장 가까운 scroll ancestor 기준으로 동작하는데, absolute 안에서는 scroll context가 달라진다.
  2. 본문 중앙 정렬을 유지하면서 TOC를 배치하기 어렵다 — flex나 grid로 배치하면 TOC가 공간을 차지해서 본문이 밀린다.

결론적으로 fixed가 가장 깔끔한 해법이었다. 뷰포트 기준으로 위치가 고정되니 레이아웃 흐름에 영향을 주지 않는다.

커서 트레일 효과

블로그에 약간의 재미 요소를 추가하고 싶었다. 마우스를 움직이면 accent 컬러의 부드러운 일렁임이 커서를 따라가는 효과를 만들었다.

Canvas + requestAnimationFrame

DOM 요소 대신 Canvas를 사용한 이유는 성능 때문이다. 매 프레임마다 여러 개의 원을 그려야 하는데, DOM 조작은 리플로우가 발생하지만 Canvas는 GPU 가속을 활용할 수 있다.

src/components/common/CursorEffect.tsx
'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 승격 트릭을 시도했지만 완전히 해결되지 않았다.

해결

결국 stickyfixed로 변경했다:

// 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하면 자동으로 배포되도록 했다:

.github/workflows/deploy.yml
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 흔들림, 스크롤바 밀림 같은 것들은 눈에 잘 띄지 않지만, 없으면 "뭔가 불편하다"는 느낌을 준다.

앞으로도 기능을 하나씩 추가하면서 이 블로그에 기록해나갈 예정이다.

관심 있을 만한 포스트