사용자가 스크롤할 때 현재 보고 있는 섹션을 네비게이션에서 하이라이트하는 패턴을 스크롤 스파이라고 합니다. 이 글에서는 실무에서 가장 안정적인 방식인 IntersectionObserver로 구현하는 방법을 Vanilla JS → React(Next.js) 순서로 정리하고, 부드러운 앵커 이동, 고정 헤더 보정, 접근성(ARIA), 자주 생기는 버그와 해결책까지 담았습니다.
scroll-margin-top 지정html { scroll-behavior: smooth; }aria-current="page"(또는 true)를 부여<header class="site-header">
<nav class="gnb">
<a href="#hero" data-link="hero">Home</a>
<a href="#about" data-link="about">About</a>
<a href="#projects"data-link="projects">Projects</a>
<a href="#skills" data-link="skills">Skills</a>
<a href="#contact" data-link="contact">Contact</a>
</nav>
</header>
<main>
<section id="hero">...</section>
<section id="about">...</section>
<section id="projects">...</section>
<section id="skills">...</section>
<section id="contact">...</section>
</main>
/* 부드러운 앵커 이동 */
html:focus-within { scroll-behavior: smooth; }
/* 고정 헤더 보정: 해시 이동 시 섹션 상단이 가려지지 않도록 */
section { scroll-margin-top: 80px; } /* 헤더 높이에 맞춰 조정 */
/* 테마/감각민감 사용자 */
@media (prefers-reduced-motion: reduce) {
* { animation: none !important; transition: none !important; scroll-behavior: auto !important; }
}
스크립트
const SECTION_IDS = ["hero", "about", "projects", "skills", "contact"];
const links = new Map(
SECTION_IDS.map(id => [id, document.querySelector(`.gnb [data-link="${id}"]`)])
);
const setActive = (id) => {
for (const [key, el] of links) {
if (!el) continue;
const isActive = key === id;
el.classList.toggle("is-active", isActive);
if (isActive) el.setAttribute("aria-current", "page");
else el.removeAttribute("aria-current");
}
};
// threshold 0.6 = 섹션의 60%가 뷰포트에 들어오면 활성화
const io = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
const id = entry.target.id;
if (entry.isIntersecting) setActive(id);
});
}, { threshold: 0.6 });
SECTION_IDS.forEach(id => {
const el = document.getElementById(id);
if (el) io.observe(el);
});
최소 스타일
.gnb a { padding: 8px 12px; border-radius: 8px; opacity: .7; }
.gnb a:hover { opacity: 1; }
.gnb a.is-active {
color: #fff; background: #111;
}
왜 IntersectionObserver?
- 스크롤 이벤트 + 스로틀/디바운스보다 리플로우/리스너 관리가 단순하고 성능이 안정적입니다.
- 요소가 뷰포트에 ‘얼마나’ 보이는지(
threshold)를 기준으로 정확한 활성 지점을 잡을 수 있습니다.
useSectionSpy"use client";
import { useEffect, useState } from "react";
const SECTION_IDS = ["hero", "about", "projects", "skills", "contact"] as const;
type SectionId = (typeof SECTION_IDS)[number];
export function useSectionSpy(threshold = 0.6) {
const [active, setActive] = useState<SectionId>("hero");
useEffect(() => {
const sections = SECTION_IDS
.map((id) => document.getElementById(id))
.filter(Boolean) as HTMLElement[];
if (!sections.length) return;
const io = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
const id = entry.target.id as SectionId;
if (entry.isIntersecting) setActive(id);
});
},
{ threshold }
);
sections.forEach((el) => io.observe(el));
return () => io.disconnect();
}, [threshold]);
return active;
}
"use client";
import Link from "next/link";
import { useSectionSpy } from "@/hooks/useSectionSpy";
export default function Header() {
const active = useSectionSpy(0.6);
const linkBase = "px-3 py-2 rounded-md transition opacity-80 hover:opacity-100";
const activeCls = "text-white bg-zinc-900 dark:bg-white dark:text-zinc-900";
const idleCls = "text-zinc-900 dark:text-zinc-200";
const item = (id: string, label: string) => (
<Link
key={id}
href={`#${id}`}
aria-current={active === id ? "page" : undefined}
className={`${linkBase} ${active === id ? activeCls : idleCls}`}
>
{label}
</Link>
);
return (
<header className="sticky top-0 z-50 bg-white/80 backdrop-blur dark:bg-zinc-900/70 border-b">
<nav className="mx-auto max-w-6xl flex gap-2 p-3">
{item("hero", "Home")}
{item("about", "About")}
{item("projects", "Projects")}
{item("skills", "Skills")}
{item("contact", "Contact")}
</nav>
</header>
);
}
Next.js(App Router) 주의
IntersectionObserver나 상태를 쓰는 컴포넌트는 반드시"use client"
해시 링크는Link href="#about"처럼 그대로 써도 됩니다.
(A) threshold 조정
threshold: 0.6 → 섹션 60%가 보이면 활성화(B) rootMargin으로 상단/하단 오프셋
고정 헤더가 높거나, 뷰포트 중앙에 들어왔을 때 활성화하고 싶다면
const io = new IntersectionObserver(callback, {
root: null,
// 상단 20%·하단 20%는 관찰 제외(중앙 근처에서만 교체되게)
rootMargin: "-20% 0px -20% 0px",
threshold: 0,
});
(C) 센티넬(가상 지점) 전략
섹션 안쪽에 보이지 않는 스팬을 박아 특정 지점에서만 활성화
<section id="about">
<span class="sentinel" aria-hidden="true"></span>
...
</section>
.sentinel { position: relative; top: 40vh; }
→ 관찰 대상은 .sentinel로 변경. “섹션의 40vh 지점에서 활성화” 같은 미세 조정이 가능합니다.
(A) 고정 헤더 가림 문제
section { scroll-margin-top: <헤더높이>; }로 대부분 해결(B) 해시 상태 동기화(선택)
활성 섹션을 주소창 해시로 반영하고 싶다면
if (entry.isIntersecting) {
history.replaceState(null, "", `#${id}`); // 스크롤만으로 URL 갱신
}
앵커 클릭 시 “튀는” 문제는 대부분 smooth-scroll + scroll-margin-top으로 해결됩니다.
(C) 접근성(필수)
aria-current="page" 부여 → 스크린리더가 현재 위치를 안내focus-visible:ring)<main className="lg:h-screen lg:overflow-y-scroll lg:snap-y lg:snap-proximity">
<section id="hero" className="min-h-screen lg:h-screen lg:snap-start">...</section>
<section id="about" className="min-h-screen lg:h-screen lg:snap-start">...</section>
...
</main>
lg: 프리픽스로 데스크톱에만 적용을 권장snap-mandatory)보다 완화형(snap-proximity)이 자연스럽습니다.1. 활성 섹션이 늦게/빨리 바뀜
→ threshold(0.4~0.7) 또는 rootMargin(-20% 0px -20% 0px)으로 조정
2. 고정 헤더 때문에 해시 이동 시 제목이 가려짐
→ section { scroll-margin-top: ... }로 해결
3. 중첩 스크롤 컨테이너(섹션이 별도 스크롤 박스에 들어간 경우)
→ IntersectionObserver의 root를 해당 컨테이너 엘리먼트로 지정
4. 동적 로딩으로 섹션이 나중에 생김
→ 섹션 DOM이 추가된 뒤 observe 다시 호출(또는 ResizeObserver와 함께 사용)
5. 다크/라이트 텍스트 충돌
→ 한 요소에 dark:text-white와 dark:text-gray-900 같이 쓰지 말고 쌍만 유지
(예: 비활성 text-zinc-900 dark:text-zinc-200, 활성 bg-zinc-900 text-white dark:bg-white dark:text-zinc-900)
// hooks/useSectionSpy.ts
"use client";
import { useEffect, useState } from "react";
const IDS = ["hero","about","projects","skills","contact"] as const;
type Id = (typeof IDS)[number];
export function useSectionSpy(threshold=0.6){
const [active, setActive] = useState<Id>("hero");
useEffect(() => {
const els = IDS.map(i=>document.getElementById(i)).filter(Boolean) as HTMLElement[];
if(!els.length) return;
const io = new IntersectionObserver((entries)=>{
entries.forEach(e => { if (e.isIntersecting) setActive(e.target.id as Id); });
}, { threshold });
els.forEach(el => io.observe(el));
return () => io.disconnect();
}, [threshold]);
return active;
}
// components/Header.tsx
"use client";
import Link from "next/link";
import { useSectionSpy } from "@/hooks/useSectionSpy";
export default function Header(){
const active = useSectionSpy(0.6);
const base = "px-3 py-2 rounded-md transition outline-none focus-visible:ring-2";
const activeCls = "bg-zinc-900 text-white dark:bg-white dark:text-zinc-900";
const idleCls = "text-zinc-900 dark:text-zinc-200 opacity-80 hover:opacity-100";
const NavLink = (id: string, label: string) => (
<Link
key={id}
href={`#${id}`}
aria-current={active === id ? "page" : undefined}
className={`${base} ${active === id ? activeCls : idleCls}`}
>
{label}
</Link>
);
return (
<header className="sticky top-0 z-50 border-b bg-white/80 backdrop-blur dark:bg-zinc-900/70">
<nav className="mx-auto max-w-6xl flex items-center gap-2 p-3">
{NavLink("hero", "Home")}
{NavLink("about", "About")}
{NavLink("projects", "Projects")}
{NavLink("skills", "Skills")}
{NavLink("contact", "Contact")}
</nav>
</header>
);
}
/* app/globals.css */
html:focus-within { scroll-behavior: smooth; }
section { scroll-margin-top: 80px; }
@media (prefers-reduced-motion: reduce) {
* { animation: none !important; transition: none !important; scroll-behavior: auto !important; }
}
스크롤 스파이는 구현 자체는 간단하지만, 헤더 가림/활성 시점/접근성/중첩 스크롤 같은 디테일에서 완성도가 갈립니다.
이 글의 기본 템플릿(IntersectionObserver + scroll-margin-top + aria-current)을 적용하면 모던하고 접근성 친화적인 스크롤 스파이를 안정적으로 만들 수 있어요.