상태 인식 고정 헤더

고정 헤더는 일반적인 UI 패턴이지만, 가독성을 유지하거나 공간을 절약하기 위해 '고정'될 때 모양을 변경해야 하는 경우가 많습니다. 기존 솔루션에는 JavaScript 스크롤 리스너가 필요하므로 성능 문제가 발생할 수 있습니다. 최신 CSS를 사용하면 스크롤 상태 쿼리를 사용하여 이를 기본적으로 처리할 수 있습니다.

구현 단계

1. 고정 컨테이너 만들기

고정 요소 역할을 하는 컨테이너와 스크롤 상태 쿼리의 컨테이너가 필요합니다.

<!-- Wrap in a section to define the containment area for the sticky element -->
<div class="section">
  <div class="sticky-container">
    <div class="sticky-header">
      Section Header
    </div>
  </div>
  <div class="content">
    <!-- Content goes here -->
  </div>
</div>

2. 고정 동작 및 컨테이너 유형에 CSS 적용

container-type: scroll-state를 스크롤 가능한 상위 요소가 아닌 position: sticky 요소에 설정합니다. 이러한 쿼리는 중첩될 수 있고 이름이 지정되지 않은 컨테이너가 충돌하므로 항상 container-name를 지정하세요. 크기 쿼리와 결합할 수도 있습니다(예: container-type: scroll-state inline-size).

.sticky-container {
  position: sticky;
  top: 0;
  /* MANDATORY: Always specify a container name to avoid collisions if CQs are nested */
  container-type: scroll-state;
  container-name: section-header;
  z-index: 10;
}

.sticky-header {
  /* Base styles for the header */
  background: #f0f0f0;
  padding: 20px;
  transition: background-color 0.3s, box-shadow 0.3s;
}

중요: 스크롤 상태는 스크롤 상태 컨테이너의 하위 요소에 의해 쿼리됩니다. @container 쿼리의 스타일은 .sticky-container 자체에는 적용되지 않고 하위 요소 (예: .sticky-header)에 적용됩니다. 컨테이너 요소 자체의 스타일은 자체 scroll-state 쿼리로 지정할 수 없습니다.

3. 정체 상태 타겟팅

@container scroll-state(...) 쿼리를 사용하여 헤더가 고정되었을 때 스타일을 적용합니다.

/* Apply styles when the named container is stuck at the top */
@container section-header scroll-state(stuck: top) {
  .sticky-header {
    background: #0056b3;
    color: white;
    box-shadow: 0 4px 6px rgb(0 0 0 / 0.15);
  }
}

도움말: stuck: inset-block-start 또는 stuck: inset-inline-start와 같은 논리적 속성을 사용하여 물리적 가장자리가 아닌 흐름 관련 가장자리를 쿼리하여 국제화 (i18n) 및 오른쪽에서 왼쪽 (RTL) 레이아웃을 더 잘 지원할 수도 있습니다.

측정기준 변경에 관한 참고사항

스크롤 고정을 사용 중지하지 않으면 고정된 상태에서 레이아웃에 영향을 미치는 속성 (높이, 패딩, 글꼴 크기)을 변경할 때 시각적 깜박임이 발생할 수 있습니다. 고정 요소 아래의 인플로우 콘텐츠에 대한 스크롤 고정은 레이아웃 변경을 보정하기 위해 스크롤 오프셋을 조정하여 요소를 고정된 위치에서 다시 밀어내고 진동을 트리거합니다.

이를 방지하려면 고정 요소의 상위 요소에서 스크롤 고정을 사용 중지하세요.

.section {
  overflow-anchor: none;
}

overflow-anchor: none를 :root 또는 body가 아닌 position: sticky 요소의 직접 상위 요소에 적용합니다. 이렇게 하면 전체 페이지의 스크롤 고정이 사용 중지됩니다. 이렇게 하면 박스 모델 속성 및 변환을 포함하여 고정 상태의 모든 속성을 자유롭게 변경할 수 있습니다.

금지사항: 하단에 고정된 요소에 overflow-anchor: none를 사용하지 마세요. 스크롤 고정은 현재 스크롤 위치 위의 레이아웃 변경만 보정하므로 하단에 고정된 요소에는 영향을 미치지 않습니다. 하단 고정 헤더의 고정 상태에서 박스 모델 속성을 변경하지 마세요.

대체 전략

스크롤 상태 쿼리는 점진적 개선입니다. container-type: scroll-state를 지원하지 않는 브라우저에서는 헤더가 여전히 상단에 고정되지만(position: sticky로 인해) 시각적으로 변환되지는 않습니다. 대부분의 사용 사례에서 이 방법이 권장됩니다.

고정 기능이 필수적이지는 않지만, 스타일을 다르게 지정하지 않은 position: sticky가 고정 기능이 전혀 없는 것보다 더 나쁜 경험을 제공한다면 position: sticky 및 관련 선언을 @supports 쿼리로 래핑할 수도 있습니다.

.sticky-container {
  @supports (container-type: scroll-state) {
      position: sticky;
      top: 0;
      container-type: scroll-state;
      container-name: section-header;
      z-index: 10;
    }
}

도움말: '고정' 스타일 지정에 가독성을 위해 다른 배경 색상이 필요한 경우 기본 스타일 지정이 모든 곳에서 작동하도록 레이아웃을 설정하는 것이 좋습니다 (점진적 개선). 대체 기능을 사용해야 하는 경우 @supports (container-type: scroll-state) 쿼리 내에서 position: sticky 동작 자체를 제어할 수 있습니다.

시각적 변환이 디자인에 매우 중요한 경우 (예: 고정 상태에서 배경이나 압축이 도입되어 콘텐츠를 읽을 수 없는 경우) IntersectionObserver를 사용하여 강력한 JavaScript 대체 기능을 구현할 수 있습니다. .is-stuck 클래스에서 CSS 스타일을 복제해야 합니다. 다음 일반 폴리필은 getComputedStyle를 확인하여 위로 이동하고 올바른 스크롤 컨테이너 (기본값은 표시 영역)를 찾아 @container scroll-state(stuck: top)의 동작과 일치시킵니다.

function getScrollParent(node) {
  if (node == null || node === document.body || node === document.documentElement) {
    return null; // default to viewport
  }
  if (node.scrollHeight > node.clientHeight || node.scrollWidth > node.clientWidth) {
    const overflow = getComputedStyle(node).overflow;
    if (overflow !== 'visible' && overflow !== 'clip') {
      return node;
    }
  }
  return getScrollParent(node.parentNode);
}

document.querySelectorAll('.sticky-container').forEach(container => {
  const root = getScrollParent(container);
  
  const topOffset = parseFloat(getComputedStyle(container).top) || 0;
  
  const observer = new IntersectionObserver(
    ([e]) => {
      // Toggle the fallback class on the sticky header container
      e.target.classList.toggle('is-stuck', e.intersectionRatio < 1);
    },
    { 
      root: root,
      threshold: [1],
      rootMargin: `-${topOffset + 1}px 0px 0px 0px` 
    }
  );
  
  observer.observe(container);
});

참고: 이 일반 IntersectionObserver 패턴은 scroll-state(scrollable) 쿼리의 폴리필로도 사용할 수 있습니다.