CSS 헤더 고정 스티키 9종 — AOS·GSAP 없이, JS 0줄
CSS 헤더 고정 스티키는 페이지를 스크롤해도 헤더가 화면 위에 계속 붙어 있게 만드는 방식입니다. 아홉 개 중 넷(04·05·08·09 중 04·05)은 자바스크립트 없이 CSS만으로 움직입니다. 아래 격자에서 자동으로 도는 아홉 개를 보고 지금 만드는 화면에 맞는 것을 찾으십시오.
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 숨김 후 재등장
- 02 스크롤 시 그림자 생김
- 03 색 반전
- 04 로고만 축소
- 05 진행바 결합
- 06 서브 내비 스티키
- 07 섹션 제목 스티키
- 08 backdrop blur 헤더
- 09 스크롤 스냅 헤더
아홉 개는 흔한 인기순이 아니라 헤더가 스크롤 속에서 겪는 순서로 놓았습니다. 방향을 아는 것(01) → 위험 신호를 알리는 것(02) → 정체성을 바꾸는 것(03) → 무게를 줄이는 것(04) → 진행을 안내하는 것(05) → 계층을 넓히는 것(06) → 맥락을 갱신하는 것(07) → 배경을 투과시키는 것(08) → 위치를 알리는 것(09) 순입니다. 아래 코드는 켜고 끄는 핵심 규칙만 뽑았고, 완결된 파일은 zip 안에 있습니다.
01숨김 후 재등장
아래로 스크롤하면 헤더가 위로 사라지고, 위로 스크롤하는 순간에만 다시 내려와 콘텐츠를 오래 가리지 않습니다. 긴 기사 페이지 헤더나 모바일 쇼핑몰 상단바에 씁니다.
var lastY = window.scrollY;
window.addEventListener('scroll', function () {
var y = window.scrollY;
head.classList.toggle('is-hidden', y > lastY && y > 40);
lastY = y;
}, { passive: true });
02스크롤 시 그림자 생김
맨 위에서는 그림자가 없다가 조금이라도 스크롤하면 헤더 아래 그라데이션 레이어의 opacity가 켜집니다. 문서·블로그 헤더처럼 옅은 배경 위 내비게이션에 어울립니다.
.head__shadow {
position: absolute;
left: 0; right: 0; bottom: -36px;
height: 36px;
background: linear-gradient(rgba($ink, .22), rgba($ink, 0));
opacity: 0;
transition: opacity $duration $easing;
}
.head.is-scrolled .head__shadow { opacity: 1; }
03색 반전
어두운 히어로 위에서는 투명 배경에 흰 글자였다가 히어로를 벗어나면 배경과 글자색이 통째로 뒤집힙니다. 풀스크린 히어로가 있는 랜딩 페이지, 포트폴리오 상단에 씁니다.
.head__layer--dark {
opacity: 1;
transition: opacity $duration $easing;
}
.head__layer--light {
background: $color;
opacity: 0;
transition: opacity $duration $easing;
}
.head.is-inverted .head__layer--dark { opacity: 0; }
.head.is-inverted .head__layer--light { opacity: 1; }
04로고만 축소
헤더 높이와 배경은 그대로 두고 로고 마크만 scale로 작아져 스크롤해도 레이아웃이 흔들리지 않습니다. 브랜드 로고가 큰 SaaS 랜딩에 씁니다.
.head__logo {
transform-origin: left center;
@supports (animation-timeline: scroll()) {
animation: logo-shrink linear both;
animation-timeline: scroll(root);
animation-range: 0 150px;
}
}
@keyframes logo-shrink {
from { transform: scale(1); }
to { transform: scale($scale-to); }
}
05진행바 결합
고정 헤더의 맨 아래 테두리에 읽은 만큼 채워지는 얇은 진행바가 붙어 헤더와 한 덩어리로 움직입니다. 긴 블로그 글 헤더, 튜토리얼 문서 상단에 씁니다.
.head__bar {
position: absolute;
left: 0; bottom: -$thickness;
width: 100%; height: $thickness;
background: $color;
transform: scaleX(0);
transform-origin: left;
animation: fill-bar linear both;
animation-timeline: scroll(root);
}
06서브 내비 스티키
메인 헤더 아래 카테고리 탭이 스크롤하면 헤더 바로 밑에 두 번째 줄로 붙어 함께 고정됩니다. 쇼핑몰 카테고리 탭, 문서 사이트 목차 탭에 씁니다.
.subnav {
position: sticky;
top: $head-h;
border-bottom: 2px solid transparent;
transition: border-color $duration $ease-out;
}
.subnav.is-stuck { border-bottom-color: rgba(255, 255, 255, .25); }
07섹션 제목 스티키
긴 목록을 스크롤하는 동안 헤더 자리에 지금 지나가는 구간의 제목만 표시되고 다음 구간에서 교체됩니다. 연락처·용어 사전처럼 알파벳 구간이 있는 목록에 씁니다.
document.querySelectorAll('.list__sentinel').forEach(function (s) {
new IntersectionObserver(function (es) {
if (es[0].isIntersecting) {
labels.forEach(function (l) {
l.classList.toggle('is-current', l.textContent === s.dataset.label);
});
}
}, { rootMargin: '-56px 0px -80% 0px' }).observe(s);
});
08backdrop blur 헤더
스크롤하면 헤더 배경이 불투명해지는 대신 backdrop-filter 블러가 켜져 뒤 콘텐츠가 흐릿하게 비칩니다. 이미지·영상 위에 얹는 헤더, iOS 스타일 앱 상단바에 씁니다.
.head {
background: rgba(255, 255, 255, .08);
backdrop-filter: blur(0);
transition: background $duration $easing, backdrop-filter $duration $easing;
}
.head.is-scrolled {
background: rgba(255, 255, 255, .55);
backdrop-filter: blur($blur);
}
09스크롤 스냅 헤더
본문이 섹션 단위로 스크롤 스냅될 때 헤더 안의 점 인디케이터가 지금 멈춘 섹션 번호에 맞춰 함께 넘어갑니다. 풀페이지 스토리텔링 사이트, 슬라이드형 제품 소개에 씁니다.
document.querySelectorAll('.snap-section').forEach(function (s) {
new IntersectionObserver(function (es) {
if (es[0].isIntersecting) {
dots.forEach(function (d, i) { d.classList.toggle('is-current', i == s.dataset.i); });
}
}, { threshold: .6 }).observe(s);
});
어디서 깨지는가 — 함정 셋
04와 05가 쓰는 animation-timeline: scroll(root)는 문서 자체가 스크롤될 때만 움직입니다. 이 zip의 데모처럼 html, body에 overflow: hidden을 걸어 iframe 안에서 스크롤바를 없앤 페이지에 그대로 붙여 넣으면 진행바가 0%에서 멈춥니다. 실제 페이지에 옮길 때는 이 두 줄부터 지워야 합니다. 두 속성 다 아직 지원이 갈리는데, caniuse의 scroll-driven animations 지원표를 보면 Chrome·Edge는 115부터, Safari는 26부터 지원하고 Firefox는 2026년 9월 현재도 정식판이 아니라 플래그 뒤에 있습니다.
02·03·06·08이 쓰는 IntersectionObserver 센티넬도 함정이 하나 있습니다. rootMargin에 헤더 높이를 하드코딩해 두면(예: -56px) 헤더 높이를 나중에 바꿨을 때 센티넬이 계속 옛 값으로 판정합니다. 헤더 높이가 반응형으로 바뀌는 페이지라면 getBoundingClientRect().height를 읽어 매번 다시 계산해야 합니다. MDN의 IntersectionObserver 문서에 rootMargin 문법이 정리돼 있습니다.
마지막은 겹치는 sticky입니다. 06처럼 헤더 아래 서브 내비를 또 sticky로 붙이면 두 요소의 top 값이 같을 때 서로를 덮습니다. 서브 내비의 top은 반드시 메인 헤더의 실제 높이와 같아야 하고, 09처럼 헤더 안에 인디케이터를 두는 경우도 헤더 자체의 z-index가 본문 섹션보다 높아야 점이 가려지지 않습니다. 이 값들이 이미 맞춰진 아홉 개 파일의 압축 비밀번호는 u9rbcprm이고, 아래 버튼으로 받을 수 있습니다.
접근성
아홉 개 전부 prefers-reduced-motion: reduce를 처리해서, 운영체제에서 "동작 줄이기"를 켠 사람에게는 전환 없이 상태만 즉시 바뀝니다. 헤더가 고정되는 기능 자체는 어떤 경우에도 지우지 않는 게 원칙입니다. 01은 전환 없이 숨거나 나타나고, 03은 즉시 반전된 상태로, 04와 09는 즉시 목표 크기로 고정됩니다. 06·07처럼 순서가 있는 요소에는 aria-current 같은 상태 속성을 얹어 스크린 리더 사용자도 지금 어느 구간인지 알 수 있게 합니다. 이 미디어 쿼리를 실제로 어디에 걸어야 하는지는 MDN 문서의 예제를 참고하십시오.
@media (prefers-reduced-motion: reduce) {
.head { transition: none !important; }
.head.is-demo { animation: none !important; }
}
헤더 축소처럼 여기서 다루지 않은 스크롤 반응은 CSS 스크롤 애니메이션 9종을 먼저 보십시오. 나머지 CSS 주제 전체 목록과 운영자 소개는 CSS 애니메이션 카테고리와 소개 페이지에서 각각 찾을 수 있습니다.
FAQ
스티키 헤더에 꼭 JS가 필요합니까?
아닙니다. 04와 05는 animation-timeline: scroll() 하나로 JS 없이 문서 스크롤에 반응합니다. 다만 caniuse의 scroll-driven animations 지원표를 먼저 확인하십시오. 나머지 항목은 방향 판정이나 임계값 판정에 짧은 IntersectionObserver 몇 줄을 씁니다.
position: sticky가 안 먹힙니다.
가장 흔한 원인은 헤더의 조상 요소 중 하나에 overflow: hidden이나 overflow: auto가 걸려 있는 경우입니다. sticky는 그 조상의 스크롤 영역 안에서만 동작하므로, 헤더부터 문서 루트까지 사이에 overflow가 잘린 요소가 없는지 먼저 확인하십시오.
06 서브 내비와 09 인디케이터를 같은 헤더에 합쳐도 됩니까?
네, 자주 쓰는 조합입니다. 다만 겹치는 함정에서 다뤘듯 두 요소의 sticky top 값과 z-index를 서로 다르게 줘야 하고, zip의 react/ 폴더는 각각 별도 컴포넌트라 필요한 것만 골라 조합하면 됩니다.