스크롤 스파이(Scroll Spy) 적용 (Vanilla JS → React/Next.js까지)

Alchemist·2025년 8월 18일

사용자가 스크롤할 때 현재 보고 있는 섹션을 네비게이션에서 하이라이트하는 패턴을 스크롤 스파이라고 합니다. 이 글에서는 실무에서 가장 안정적인 방식인 IntersectionObserver로 구현하는 방법을 Vanilla JS → React(Next.js) 순서로 정리하고, 부드러운 앵커 이동, 고정 헤더 보정, 접근성(ARIA), 자주 생기는 버그와 해결책까지 담았습니다.


요약

  • IntersectionObserver로 각 섹션 가시성을 관찰 → 현재 섹션 id를 상태로 보관 → 네비에서 해당 링크만 활성화
  • 고정 헤더를 쓰면 섹션에 scroll-margin-top 지정
  • 앵커 이동은 CSS 한 줄: html { scroll-behavior: smooth; }
  • 접근성: 현재 항목에 aria-current="page"(또는 true)를 부여
  • 모바일/트랙패드 고려해 과도한 scroll-snap은 지양(선택적으로 데스크톱에만)

1) 마크업 구조(공통)

<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; }
}

2) Vanilla JS 구현 (IntersectionObserver)

스크립트

  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)를 기준으로 정확한 활성 지점을 잡을 수 있습니다.

3) React(Next.js) 구현

3-1) Hook: 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;
}

3-2) Header 예시

"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"처럼 그대로 써도 됩니다.

4) 스크롤 위치 기준 더 정교하게 잡기

(A) threshold 조정

  • threshold: 0.6 → 섹션 60%가 보이면 활성화
  • 긴 섹션은 0.3–0.5, 짧은 섹션은 0.6–0.8 권장

(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 지점에서 활성화” 같은 미세 조정이 가능합니다.

5) 라우팅/해시/UX 디테일

(A) 고정 헤더 가림 문제

  • section { scroll-margin-top: <헤더높이>; }로 대부분 해결
  • 헤더 높이가 반응형으로 달라지면 부여하는 값도 여유 있게(예: 80–96px)

(B) 해시 상태 동기화(선택)
활성 섹션을 주소창 해시로 반영하고 싶다면

if (entry.isIntersecting) {
  history.replaceState(null, "", `#${id}`); // 스크롤만으로 URL 갱신
}

앵커 클릭 시 “튀는” 문제는 대부분 smooth-scroll + scroll-margin-top으로 해결됩니다.

(C) 접근성(필수)

  • 현재 섹션 링크에 aria-current="page" 부여 → 스크린리더가 현재 위치를 안내
  • 키보드 포커스 링은 반드시 보이게(예: focus-visible:ring)

6) 선택: 데스크톱에만 약한 scroll-snap

<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>
  • 모바일/트랙패드는 과한 스냅이 UX를 해칠 수 있어 lg: 프리픽스로 데스크톱에만 적용을 권장
  • 스냅 강제(snap-mandatory)보다 완화형(snap-proximity)이 자연스럽습니다.

7) 자주 겪는 문제와 해결

1. 활성 섹션이 늦게/빨리 바뀜
threshold(0.4~0.7) 또는 rootMargin(-20% 0px -20% 0px)으로 조정

2. 고정 헤더 때문에 해시 이동 시 제목이 가려짐
section { scroll-margin-top: ... }로 해결

3. 중첩 스크롤 컨테이너(섹션이 별도 스크롤 박스에 들어간 경우)
IntersectionObserverroot를 해당 컨테이너 엘리먼트로 지정

4. 동적 로딩으로 섹션이 나중에 생김
→ 섹션 DOM이 추가된 뒤 observe 다시 호출(또는 ResizeObserver와 함께 사용)

5. 다크/라이트 텍스트 충돌
→ 한 요소에 dark:text-whitedark:text-gray-900 같이 쓰지 말고 쌍만 유지
(예: 비활성 text-zinc-900 dark:text-zinc-200, 활성 bg-zinc-900 text-white dark:bg-white dark:text-zinc-900)

8) 완성 예시(Next.js App Router)

// 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)을 적용하면 모던하고 접근성 친화적인 스크롤 스파이를 안정적으로 만들 수 있어요.

profile
html_programming_language

0개의 댓글