본문으로 건너뛰기
ff1451 logo ff1451

CSS scroll-target-group으로 목차 하이라이트 구현하기

scroll-target-group로 TOC 하이라이트를 단순화하고, 폴백으로 호환성을 유지한 과정

7 min read
#css

이 블로그의 목차(TOC) 하이라이트는 원래 IntersectionObserver로 구현했습니다. 처음에는 잘 동작해서 그대로 둘까 했지만, observer 설정·콜백·DOM 조작·페이지 하단 예외 처리까지 더해지면서 코드가 약 60줄로 늘어났습니다. 특히 rootMargin: '0px 0px -70% 0px' 같은 값은 여러 번 테스트해 감으로 맞추는 과정이 필요했습니다.

이번 글에서는 이 로직을 CSS scroll-target-group 기반으로 바꿔 코드를 약 10줄로 줄인 과정과, 미지원 브라우저에서 기존 방식을 유지한 Progressive Enhancement 전략을 정리합니다.

1. scroll-target-group이란?

scroll-target-group은 CSS Overflow Level 5에 포함된 속성으로, 앵커 링크 목록을 스크롤 마커 그룹으로 선언할 때 사용합니다. 적용 후에는 브라우저가 스크롤 위치를 기준으로 현재 항목을 자동 판단해, 해당 링크에 :target-current를 붙여줍니다.

브라우저 지원 현황

  • Chrome 140+ (2025년 9월 출시)
  • Safari, Firefox: 미지원 (2026년 2월 기준)

Chrome 사용자에게는 바로 이점을 줄 수 있지만, Safari와 Firefox를 고려하면 폴백 전략이 여전히 필요합니다.

2. 코드 비교

Before: IntersectionObserver (60줄)

const observerOptions = {
  root: null,
  rootMargin: '0px 0px -70% 0px',
  threshold: 0
};

const observer = new IntersectionObserver((entries) => {
  entries.forEach((entry) => {
    const id = entry.target.getAttribute('id');
    const link = document.querySelector(`.toc-item a[href="#${id}"]`);
    if (!link) return;

    const item = link.closest('.toc-item');

    if (entry.isIntersecting) {
      // Remove active class from all items
      document.querySelectorAll('.toc-item').forEach((i) => {
        i.classList.remove('border-gray-900', 'dark:border-white');
        i.classList.add('border-transparent');
      });

      // Add active class to current item
      if (item) {
        item.classList.remove('border-transparent');
        item.classList.add('border-gray-900', 'dark:border-white');
      }
    }
  });

  // 페이지 하단 감지 로직...
}, observerOptions);

document.querySelectorAll('article h1, article h2, ...').forEach((section) => {
  observer.observe(section);
});

After: scroll-target-group (10줄)

<nav class="toc-nav">
  <ul>
    <li class="toc-item">
      <a href="#section-1">Section 1</a>
    </li>
  </ul>
</nav>
@supports (scroll-target-group: auto) {
  .toc-nav {
    scroll-target-group: auto;
  }

  .toc-item:has(a:target-current) {
    border-color: #111827;
  }

  .toc-item a:target-current {
    color: #111827;
    font-weight: 500;
  }
}

코드량은 6배 줄었고, 엣지케이스 처리 로직도 사라졌습니다.

3. 구현 과정의 트러블 슈팅

문제: Lightning CSS가 :target-current를 파싱하지 못함

이 블로그는 Tailwind CSS v4를 사용하고, 빌드 단계에서 Lightning CSS가 스타일을 파싱합니다. 그런데 global.css:target-current를 작성하자 아래 에러가 발생했습니다.

'target-current' is not recognized as a valid pseudo-class.

원인은 단순했습니다. Lightning CSS가 아직 :target-current를 유효한 pseudo-class로 인식하지 못합니다.

해결: <style is:inline>으로 파서 경유를 피함

Astro의 is:inline을 사용하면 해당 CSS를 번들 파이프라인에 태우지 않고 HTML에 그대로 삽입할 수 있습니다.

<style is:inline>
@supports (scroll-target-group: auto) {
  .toc-nav {
    scroll-target-group: auto;
  }

  .toc-item a:target-current {
    color: #111827;
    font-weight: 500;
  }
}
</style>

최신 CSS 스펙을 빌드 도구가 아직 따라오지 못할 때 쓸 수 있는 우회 방법입니다. 물론 is:inline으로 넣은 CSS에는 압축·중복 제거 같은 최적화가 적용되지 않는 단점이 있습니다. 다만 이 사례처럼 짧은 스니펫이라면 큰 문제는 되지 않습니다.

4. Progressive Enhancement 전략

새로운 CSS 기능을 넣을 때 핵심은 지원 브라우저와 미지원 브라우저를 분리해 안전하게 배포하는 일입니다. 여기서는 feature detection + fallback 조합으로 접근했습니다.

if (CSS.supports('scroll-target-group', 'auto')) {
  // 지원 브라우저: CSS만으로 동작, JS는 최소화
  let prevActive: Element | null = null;

  window.addEventListener('scroll', () => {
    const active = document.querySelector('.toc-item a:target-current');
    if (active && active !== prevActive) {
      active.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
      prevActive = active;
    }
  });
} else {
  // 미지원 브라우저: IntersectionObserver 폴백
  const observer = new IntersectionObserver((entries) => {
    // ... 기존 로직
  }, observerOptions);
}
  • Chrome 140+ 사용자: CSS가 하이라이트를 처리하고, JS는 사이드바 스크롤만 담당
  • 기타 브라우저 사용자: 기존 IntersectionObserver로 동일 기능 제공

요약하면 기능은 동일하게 유지하고, 가능한 환경에서만 더 단순하고 가벼운 구현을 선택한 셈입니다.

5. scroll-target-group 내부 구현

scroll-target-group을 적용하면서, 브라우저가 “현재 활성 항목”을 어떤 기준으로 선택하는지 살펴봤습니다. 아래에는 스펙 기준으로 동작 흐름만 간단히 정리했습니다.

Active Scroll Marker 선택 알고리즘

브라우저는 스크롤 시점마다 아래 단계를 거쳐 활성 마커를 결정합니다.

1단계: Scroll Marker Group 구성

<nav class="toc-nav">  <!-- scroll-target-group: auto -->
  <a href="#intro">Introduction</a>
  <a href="#method">Method</a>
  <a href="#result">Result</a>
</nav>

scroll-target-group: auto가 적용된 컨테이너를 찾으면, 내부 앵커 링크(<a href="#...">)를 scroll marker로 등록합니다. 각 마커는 href의 fragment identifier를 통해 타겟 요소(예: <h2 id="intro">)에 매핑됩니다.

2단계: 공통 스크롤 컨테이너 찾기

모든 타겟 요소(#intro, #method, #result)의 가장 가까운 공통 스크롤 조상(nearest common scroll container)을 찾습니다. 단일 페이지 스크롤에서는 보통 document가 선택되고, 복잡한 레이아웃에서는 특정 overflow: auto 컨테이너가 선택될 수 있습니다.

3단계: Eventual Scroll Position 계산

Eventual scroll position은 “스크롤이 멈출 것으로 예상되는 최종 위치”를 뜻합니다.

  • 일반 스크롤: 현재 scrollTop
  • 스크롤 애니메이션 중: 애니메이션이 도달할 목표 위치
  • 관성 스크롤(momentum scroll) 중: 감속을 고려한 예상 정지 위치

이 개념이 중요한 이유는 스크롤 애니메이션 중에도 올바른 항목을 즉시 활성화하기 때문입니다. IntersectionObserver는 요소가 실제로 뷰포트에 진입한 뒤 콜백이 실행되는 반면 scroll-target-group은 “스크롤이 멈췄을 때의 위치”를 먼저 계산해 반응합니다.

4단계: 각 타겟의 Scroll-into-View 위치 계산

각 타겟 요소가 뷰포트 상단에 정렬되려면 스크롤이 어디까지 이동해야 하는지 계산합니다.

타겟의 scroll-into-view 위치 = 타겟의 offsetTop - 스크롤 컨테이너의 padding-top

예시:

  • #intro의 offsetTop이 100px → 100px 스크롤 시 활성화
  • #method의 offsetTop이 500px → 500px 스크롤 시 활성화
  • #result의 offsetTop이 1000px → 1000px 스크롤 시 활성화

5단계: Active Marker 선택

스크롤 위치(또는 eventual position)와 각 타겟의 scroll-into-view 위치를 비교합니다.

현재 스크롤 위치 = 350px일 때
- #intro (100px) ≤ 350px     [조건 만족]
- #method (500px) > 350px    [조건 불만족]
- #result (1000px) > 350px   [조건 불만족]

→ #intro가 활성 마커

스펙 규칙은 “현재 스크롤 위치 이하에 있는 마커 중 마지막 것” 입니다. 여러 섹션이 동시에 조건을 만족하면 트리 순서상 가장 나중 마커가 활성화됩니다.

페이지 하단 엣지케이스 자동 처리

IntersectionObserver 구현에서 특히 까다로웠던 부분은 마지막 섹션이 뷰포트를 다 채우지 못하는 경우였습니다.

마지막 섹션의 높이가 200px인데 뷰포트가 800px이라면, 스크롤을 끝까지 내려도 마지막 섹션이 뷰포트 상단에 도달할 수 없습니다. IntersectionObserver는 이 상황을 “intersecting”으로 감지하지 못해 마지막 항목이 활성화되지 않는 문제가 생깁니다.

scroll-target-group에는 이를 보완하기 위해 뷰포트 크기의 1/8(12.5%)까지 타겟 위치를 재분배하는 공식이 포함됩니다.

if (타겟의 offsetTop + 타겟의 높이 < 스크롤 컨테이너의 높이) {
  조정된 위치 = max(
    원래 offsetTop,
    (전체 콘텐츠 높이 - 뷰포트 높이 * 7/8)
  )
}

이 공식 덕분에 스크롤을 87.5% 이상 내리면 마지막 타겟이 자동으로 활성화됩니다. IntersectionObserver에서는 별도의 scroll event listener와 조건문을 직접 작성해야 했지만, scroll-target-group에서는 이 처리를 브라우저가 담당합니다.

IntersectionObserver와의 개념적 차이

측면IntersectionObserverscroll-target-group
동작 방식요소가 뷰포트에 진입/이탈할 때 이벤트 발생스크롤 위치 기반으로 계산
반응 시점요소가 실제로 교차한 후Eventual position 기반으로 즉시
처리 위치메인 스레드 (JS 콜백)컴포지터 스레드 (네이티브)
개념적 유사성Intersection 감지scroll-snap과 유사
튜닝 가능성rootMargin, threshold 조정 가능브라우저 알고리즘에 위임

한계점과 트레이드오프

CSS 선언 방식은 간결하지만, 그만큼 포기해야 하는 지점도 분명했습니다.

1. 알고리즘을 커스터마이징할 수 없다

IntersectionObserver는 rootMargin, threshold를 조정해 감지 영역을 바꿀 수 있습니다. 반면 scroll-target-group은 브라우저가 정한 알고리즘을 그대로 사용해야 합니다. “뷰포트 중앙에 있는 섹션을 활성화”처럼 커스텀 로직이 필요하다면, 결국 IntersectionObserver로 돌아가야 합니다.

2. 디버깅이 어렵다

JS라면 console.log로 활성 항목을 실시간 추적할 수 있습니다. 하지만 CSS pseudo-class는 브라우저 내부에서 처리되기 때문에, DevTools Elements 패널에서 스타일을 확인하는 방법 외에는 추적 수단이 제한적입니다.

3. 여전히 JS가 필요하다

목차 사이드바가 스크롤 가능한 경우(overflow-y: auto), 활성 항목이 항상 보이게 유지하려면 JS가 필요합니다. CSS만으로는 “다른 컨테이너를 스크롤”하는 게 불가능하기 때문입니다.

// 여전히 이 로직은 JS로 작성해야 함
window.addEventListener('scroll', () => {
  const active = document.querySelector('.toc-item a:target-current');
  active?.scrollIntoView({ block: 'nearest' });
});

6. IntersectionObserver vs scroll-target-group

성능

  • scroll-target-group: 브라우저 렌더링 엔진에서 처리되어 JS 메인 스레드 의존도가 낮습니다.
  • IntersectionObserver: 콜백이 메인 스레드에서 실행되고, 보통 DOM 조회/조작이 함께 발생합니다.

이론적으로는 scroll-target-group 쪽이 유리하지만, 블로그 규모(목차 5~20개)에서는 체감 차이가 거의 없었습니다. IntersectionObserver 콜백도 0.1ms 미만으로 짧아 DevTools에서 유의미한 차이를 잡기 어려웠습니다.

다만 목차 항목이 수백 개이거나 저사양 모바일 기기라면 차이가 커질 가능성은 있습니다.

정확도

  • scroll-target-group: 브라우저가 네이티브 알고리즘으로 계산하며, eventual scroll position(애니메이션 도달 예정 위치)까지 반영합니다.
  • IntersectionObserver: rootMargin: '0px 0px -70% 0px' 같은 임계값 튜닝이 필요합니다. 빠른 스크롤에서는 여러 entry가 동시에 intersecting되어 하이라이트가 흔들릴 수 있습니다.

코드 복잡도

  • scroll-target-group: CSS 약 10줄, 브라우저 기본 알고리즘에 위임
  • IntersectionObserver: JS 약 60줄, observer 설정 + 콜백 + DOM 조작 + 하단 엣지케이스 처리

정리하면 이 글의 선택 기준은 “최대 성능”보다는 “충분한 성능 + 낮은 복잡도”에 가깝습니다.

7. 마무리

결론부터 말씀드리면, 이번 적용의 핵심 성과는 성능 수치보다 구현 단순화였습니다. IntersectionObserver 기반 코드(약 60줄)를 CSS 중심 구조(약 10줄)로 바꾸면서 엣지케이스 처리 부담도 크게 줄었습니다.

물론 한계는 남았습니다. Lightning CSS 우회가 필요했고, 브라우저 지원 범위 때문에 폴백 코드를 계속 유지해야 했습니다.

그래도 Progressive Enhancement 전략을 적용하면, 지원 브라우저에는 더 단순한 경로를 제공하고, 미지원 브라우저에는 기존 동작을 안정적으로 유지할 수 있습니다.

8. 참고 자료