GODRICH

CSS 헤더 고정 9가지 응용 — 검색바·CTA·배지까지

CSS 헤더 고정은 스크롤해도 페이지 위쪽 메뉴 줄이 화면에 그대로 붙어 있게 만드는 기능으로, position: sticky 한 줄이면 충분합니다.

자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip

아홉 개는 인기 순이 아니라 헤더 안에서 무엇이 바뀌는지에 따라 네 갈래로 묶입니다. 공간을 아끼려고 무언가 접히거나 좁아지는 쪽(01·02·07), 스크롤 위치에 맞춰 안내 정보가 움직이거나 드러나는 쪽(03·06), 검색 UI가 넓어지거나 다른 줄과 합쳐지는 쪽(04·05), 마지막으로 화면에 없던 요소가 새로 튀어나오는 쪽(08·09)입니다.

01공지 배너 접힘

페이지 맨 위 프로모션 배너 줄이 스크롤을 시작하자마자 레이아웃을 밀어내지 않고 위로 사라지고, 그 빈 자리로 로고 줄이 올라옵니다. 배너와 헤더를 같은 슬롯에 절대 위치로 겹쳐 두고 transform만 움직이기 때문에 배너가 사라지는 동안 레이아웃이 출렁이지 않습니다.

translateY(-100%)2단→1단scroll 임계값
.ahc__bar {
  position: absolute; inset: 0 0 auto 0;
  height: 32px;
  transform: translateY(0);
  opacity: 1;
  transition: transform .3s ease-out, opacity .3s ease-out;
}
.ahc__head {
  position: absolute; left: 0; right: 0; top: 32px;
  height: 56px;
  transform: translateY(0);
  transition: transform .3s ease-out;
}

// 실제 사용: 스크롤을 시작하면 배너가 위로 사라지고 헤더가 그 빈 자리로 올라온다
.ahc.is-scrolled {
  .ahc__bar { transform: translateY(-100%); opacity: 0; }
  .ahc__head { transform: translateY(-32px); }
}

02아이콘만 남는 내비

내비게이션 링크의 글자 라벨이 스크롤하면 폭과 함께 사라지고 아이콘만 남아, 좁아진 헤더 폭을 아낍니다. opacity만 바꾸면 빈 여백이 남으므로 max-width도 함께 0으로 줄여야 아이콘들이 실제로 왼쪽으로 붙습니다.

width:0 축소opacity 페이드clip 텍스트
.ioc__label {
  overflow: hidden;
  white-space: nowrap;
  max-width: 40px;
  transform-origin: left center;
  transition: max-width .3s ease-out, transform .3s ease-out, opacity .3s ease-out;
}

// 실제 사용: 스크롤하면 라벨이 폭과 함께 접혀 아이콘만 남는다
.ioc.is-scrolled .ioc__label { max-width: 0; transform: scaleX(0); opacity: 0; }

03탭 밑줄 스크롤 동기화

헤더 안에 나란히 놓인 앵커 탭들 아래로 밑줄 하나가, 지금 지나가는 섹션에 맞춰 옆 탭으로 미끄러져 갑니다. 실제 페이지에서는 IntersectionObserver로 통과 중인 섹션을 찾아 그 탭의 위치로 밑줄을 옮깁니다.

translateX 고정폭@keyframes tus-loopflex:0 0 auto
// 탭 폭이 서로 달라 offsetLeft·offsetWidth 를 실측해 옮긴다 — 짐작 px 금지
var nav = document.getElementById('tusNav');
var tabs = nav.querySelectorAll('.tus__tab');
var underline = nav.querySelector('.tus__underline');
function moveTo(tab) {
  var w = underline.offsetWidth;
  underline.style.transform =
    'translateX(' + (tab.offsetLeft + tab.offsetWidth / 2 - w / 2) + 'px)';
}

04검색 바 흡수

헤더 아래 따로 떠 있던 검색 입력 줄이 스크롤하면 위로 미끄러져 메인 헤더 한 줄 안으로 합쳐집니다. 두 줄을 고정 높이 슬롯에 겹쳐 두고 검색 줄만 헤더 높이만큼 translateY로 끌어올리는 방식이라, 01번과 같은 층 겹치기 구조를 반대 방향으로 씁니다.

2행→1행translateY 합치기높이 트랜지션
.sbm__search {
  position: absolute; left: 0; right: 0; top: 56px;
  height: 40px;
  border-top: 1px solid rgba($stage-ink, .08);
  transform: translateY(0);
  transition: transform .3s ease-out;
}

// 실제 사용: 스크롤을 시작하면 검색 줄이 위로 미끄러져 헤더 줄 안으로 합쳐진다
.sbm.is-scrolled .sbm__search { transform: translateY(-56px); }

05검색창 확장

돋보기 아이콘 하나였던 자리가 클릭하면 폭이 넓어지며 실제 입력창으로 바뀝니다. 아홉 개 중 유일하게 스크롤과 무관한 항목으로, 트리거는 스크롤 위치가 아니라 클릭(포커스)입니다.

transition: width아이콘→입력창포커스 트리거
.sse__search {
  width: 36px;
  border-radius: $r-pill;
  background: rgba($subject-cream, .12);
  overflow: hidden;
  transition: width .3s ease-spring, background .3s ease-spring;

  &.is-open { width: 156px; background: $subject-cream; }
}

06브레드크럼 등장

로고 옆 비어 있던 자리에, 스크롤하는 순간 지금 보고 있는 위치 경로가 페이드와 슬라이드로 나타납니다. 자바스크립트는 클래스 하나만 붙이고, 실제 움직임(opacity·translateX)은 전부 CSS transition이 맡습니다.

opacity+translateX페이지 위치 표시순수 CSS
.head__crumb {
  display: flex; align-items: center; gap: $sp-2;
  opacity: 0;
  transform: translateX(-16px);
  transition: opacity .2s ease-spring, transform .2s ease-spring;
}

// 실제 사용: 스크롤하면 JS가 붙인 클래스가 브레드크럼을 페이드+슬라이드로 보여준다
.head.is-scrolled .head__crumb { opacity: 1; transform: translateX(0); }

07유저 메뉴 압축

우측 아바타 옆에 붙어 있던 사용자 이름 글자가 스크롤하면 폭과 함께 접혀 아바타 아이콘만 남습니다. 02번(아이콘만 남는 내비)과 같은 max-width 접기 기법을 오른쪽 정렬 요소에 적용한 형태라, margin-right도 함께 0으로 줄여야 아바타가 실제로 오른쪽 끝에 붙습니다.

max-width 축소텍스트 clip아바타만 유지
.head__name {
  display: inline-block;
  max-width: 104px;
  margin-right: $sp-2;
  overflow: hidden;
  white-space: nowrap;
  transform: scaleX(1);
  transform-origin: left;
  transition: max-width .3s ease-out, margin-right .3s ease-out,
              opacity .3s ease-out, transform .3s ease-out;
}

.head.is-scrolled .head__name {
  max-width: 0; margin-right: 0; opacity: 0; transform: scaleX(0);
}

08CTA 버튼 슬라이드인

본문을 일정 구간 이상 내려야 헤더 오른쪽 끝에 구매·문의 버튼이 화면 밖에서 미끄러져 들어와 자리 잡습니다. 아홉 개 중 스크롤 임계값이 가장 커서(120px), 페이지에 발을 들이자마자 버튼부터 들이대지 않습니다.

translateX(100%)→0스크롤 임계값transform만
.head__cta {
  transform: translateX(140%);
  transition: transform .3s ease-spring;
}

// 실제 사용: 120px 넘게 스크롤해야 버튼이 화면 밖에서 미끄러져 들어온다
.head.is-scrolled .head__cta { transform: translateX(0); }

09알림 뱃지 등장

스크롤이 80px을 넘는 순간 헤더 아이콘 위 알림 점이 튀어나오듯 커졌다가, 그 뒤로는 은은하게 맥박치기를 반복합니다. 점이 나타나는 순간용 키프레임과 나타난 뒤 계속 도는 맥박용 키프레임을 분리해 둔 게 특징입니다.

scale pop반복 pulsescroll 임계값
.head__dot {
  transform: scale(0);
  opacity: 0;
  transition: transform .15s ease-pop, opacity .15s ease-pop;
}

.head.is-scrolled .head__dot {
  transform: scale(1);
  opacity: 1;
  animation: dot-pulse-real 1.6s ease-in-out infinite;
}
@keyframes dot-pulse-real {
  0%, 100% { transform: scale(1); opacity: 1; }
  50%      { transform: scale(1.3); opacity: .7; }
}

CSS 헤더 고정, 어디서 깨지는가

가장 자주 걸려 넘어지는 건 03번 같은 슬라이딩 인디케이터입니다. 밑줄이나 필처럼 탭 사이를 미끄러져 다니는 요소는 탭 폭이 서로 다르면(이 데모에서 "개요" 61.45px·"리포트" 76.17px·"설정" 61.45px) translateX 값을 짐작으로 넣기 쉬운데, 균등폭이 아닌 이상 그 짐작은 항상 틀립니다. 이 항목은 flex: 0 0 auto로 탭 폭을 내용에 맞게 고정한 뒤 offsetLeft·offsetWidth를 실제로 읽어 밑줄 위치를 계산합니다(18.73px·87.54px·156.35px). 같은 실수를 CSS만으로 재현해보면, 탭에 flex: 1 1 0(균등분배)을 주고 고정폭 그대로 옮기면 밑줄이 짧은 탭에서는 못 미치고 긴 탭에서는 넘칩니다. 이 03번 폴더를 직접 열어 탭 글자 수를 바꿔보면 밑줄이 실측값을 따라 다시 계산되는지 바로 확인할 수 있습니다.

두 번째로 주의할 점은 05번(검색창 확장)입니다. 이름만 보면 "스티키 헤더" 모음에 속하지만 실제로는 스크롤과 무관하게 클릭으로만 열려, 스크롤 이벤트 리스너를 기대하고 코드를 읽으면 없어서 당황하게 됩니다. 04번처럼 두 층을 겹쳐 쌓은 슬롯 구조도 순서를 하나만 틀리면(절대 위치 레이어의 z-index를 안 주면) 검색 줄이 로고 위로 올라와 버리니 겹침 순서를 확인해야 합니다 — 이 아홉 개 파일을 담은 zip의 비밀번호는 weqydbxa이고, 압축을 풀면 같은 vanilla/·react/ 구조로 직접 열어 비교해 볼 수 있습니다.

접근성 — 아홉 개가 reduced-motion에서 하는 일

prefers-reduced-motion: reduce가 켜진 사람에게 이 아홉 개는 서로 조금씩 다르게 반응합니다. 01·02·04·06·07·08번은 transition: none으로 전환 시간만 없애서, 실제 페이지에서 스크롤을 넘기면 배너가 접히고 라벨이 줄고 이름이 사라지는 결과 자체는 그대로 일어나되 순간적으로 바뀝니다 — 기능은 지키고 애니메이션만 뺀 것입니다. 09번(알림 뱃지)만 다르게 처리했는데, 전환뿐 아니라 스크롤 이후에도 계속 반복되는 dot-pulse-real 맥박 자체를 꺼서 점이 한 번 나타난 뒤로는 완전히 정지합니다 — 끝없이 반복되는 애니메이션은 전환과 달리 그 자체가 움직임이라 따로 꺼야 합니다. 03번(탭 밑줄)과 05번(검색창)은 애초에 스크롤에 자동으로 반응하지 않으니 줄일 "전환"도 사용자가 클릭한 순간에만 있고, 그 전환의 시간만 없앱니다.

스크롤에 반응해 내용물이 움직이는 헤더를 더 보고 싶다면 CSS 스티키 헤더 9종 글에서 헤더 자체가 접히고 그림자가 지는 기본형을 다룹니다. 헤더 하나만 다루지 않고 스크롤 전반의 연출을 모은 CSS 스크롤 애니메이션 9종도 참고할 만합니다. 다른 주제 모음은 CSS 애니메이션 카테고리에서 둘러볼 수 있습니다. position: sticky의 스크롤 컨테이너 규칙은 MDN 레퍼런스에, 03번이 쓰는 뷰포트 감지는 MDN IntersectionObserver 문서에 정리돼 있습니다.

FAQ — 자주 묻는 질문

아홉 개 전부 스크롤해야 움직이나요?

아니요. 7개(01·02·04·06·07·08·09)는 스크롤 위치(20~120px 임계값)에 반응하고, 03번은 어느 섹션이 보이는지에 반응하지만, 05번(검색창 확장)은 스크롤과 무관하게 클릭으로만 열립니다.

헤더가 여러 층(배너+헤더, 헤더+검색)으로 겹칠 때 왜 height 대신 transform을 쓰나요?

heightmargin을 애니메이션하면 브라우저가 매 프레임 레이아웃을 다시 계산해야 해서 버벅이기 쉽습니다. 대신 두 층을 절대 위치로 겹쳐 두고 transform: translateY()만 움직이면 레이아웃 계산 없이 합성 레이어만 바뀌어 더 부드럽습니다. 01·04번이 이 방식입니다.

vanilla와 react 코드가 다른가요?

움직임 자체는 같습니다. 차이는 트리거뿐인데, vanilla 데모는 is-demo 클래스로 끊임없이 반복 재생하지만 React 컴포넌트는 실제 scroll 이벤트(또는 03번은 IntersectionObserver)를 직접 듣고 있다가 조건이 맞을 때만 클래스를 바꿉니다.

압축 비밀번호를 입력하세요

비밀번호는 이 글 본문 안에 있습니다. 읽다 보면 나옵니다.