position: sticky로 헤더를 상단에 고정하는 건 쉽다. 근데 늘 아쉬운 게 하나 있었다. 헤더가 스크롤되다가 실제로 상단에 "붙는" 순간에만 그림자나 배경을 넣고 싶은데, 이게 CSS로는 안 됐다.
지금까지는 이걸 하려고 IntersectionObserver를 썼다. 헤더 위에 보이지 않는 센티널(sentinel) 요소를 하나 두고, 그게 화면 밖으로 나가면 헤더가 붙었다고 판단해서 클래스를 토글하는 식이다. 동작은 하는데 DOM에 더미 요소를 심어야 하고, 옵저버 세팅하는 코드가 은근히 번거롭다.
scroll-state 컨테이너 쿼리
이제 CSS만으로 된다. container-type: scroll-state로 컨테이너를 선언하면, 그 안에서 스크롤 상태를 쿼리할 수 있다. 그중 stuck 상태가 딱 이 문제를 위한 거다.
핵심은 감지할 요소와 그 상태를 읽을 요소를 나눠야 한다는 점이다. sticky 요소 자신을 컨테이너로 만들고, 안쪽 자식에서 상태를 쿼리한다.
.header {
position: sticky;
top: 0;
container-type: scroll-state;
}
.header__inner {
transition: box-shadow 0.2s, background 0.2s;
}
/* 헤더가 상단(top)에 붙었을 때만 적용 */
@container scroll-state(stuck: top) {
.header__inner {
background: white;
box-shadow: 0 2px 8px rgb(0 0 0 / 0.12);
}
}
<header class="header">
<div class="header__inner">로고 · 메뉴</div>
</header>
stuck: top은 sticky 요소가 컨테이너 상단 경계에 밀착했을 때 참이 된다. bottom, left, right도 있어서, 하단에 고정한 툴바 같은 것도 같은 방식으로 처리된다. 센티널 요소도, 스크롤 이벤트 리스너도 없다.
snap 상태도 읽을 수 있다
같은 계열로 snapped 상태도 있다. 스크롤 스냅 컨테이너에서 어떤 항목이 현재 스냅 위치에 걸렸는지를 CSS로 감지한다. 캐러셀에서 지금 보이는 슬라이드만 강조하고 싶을 때 유용하다.
.slide {
container-type: scroll-state;
}
@container scroll-state(snapped: x) {
.slide__label { opacity: 1; }
}
정리
scroll-state 컨테이너 쿼리는 아직 크로미엄 기반 브라우저 위주로 지원된다. 그래서 "붙었을 때 그림자"처럼 없어도 되는 장식에 쓰는 게 안전하다. 기본 스타일은 그대로 두고 @container 블록에서 강조만 얹으면, 지원 안 하는 브라우저에서는 그냥 그림자가 안 생길 뿐 레이아웃은 멀쩡하다.
몇 년간 JS로 우회하던 걸 CSS 선언 몇 줄로 끝낼 수 있다는 게 제일 반갑다. sticky 헤더 그림자 하나 넣자고 옵저버 붙이던 시절은 이제 접어도 될 것 같다.