GODRICH
#css애니메이션#버튼#로딩#전환효과#스크롤#대시보드#실전ui#디자인트렌드

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

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

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

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

01공지 배너 접힘

배너가 위로 사라진다

페이지 맨 위 프로모션 배너 줄이 스크롤을 시작하자마자 레이아웃을 밀어내지 않고 위로 사라지고, 그 빈 자리로 로고 줄이 올라옵니다.

공지 배너 접힘
새 창에서 열기

01 · 공지 배너 접힘 — 측정 누적면적 23.972% · 움직인 프레임 12/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

translateY(-100%)2단→1단scroll 임계값

레이아웃이 출렁이지 않는 이유

배너와 헤더를 같은 슬롯에 절대 위치로 겹쳐 두고 transform만 움직이기 때문에 배너가 사라지는 동안 레이아웃이 출렁이지 않습니다.

.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); }
}

배너·헤더가 접히는 이징은 ease-out 대신 관성 lerp 커브 — p += (목표 - p) * α를 스텝마다 반복한 결과값을 CSS linear() 함수로 구워 넣어, 스크롤을 멈춘 뒤에도 헤더가 잠깐 더 밀리며 따라옵니다.

02아이콘만 남는 내비

글자 라벨이 사라진다

내비게이션 링크의 글자 라벨이 스크롤하면 폭과 함께 사라지고 아이콘만 남아, 좁아진 헤더 폭을 아낍니다.

아이콘만 남는 내비
새 창에서 열기

02 · 아이콘만 남는 내비 — 측정 누적면적 1.844% · 움직인 프레임 14/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

width:0 축소opacity 페이드clip 텍스트

max-width 도 같이 줄여야 한다

opacity만 바꾸면 빈 여백이 남으므로 max-width도 함께 0으로 줄여야 아이콘들이 실제로 왼쪽으로 붙습니다.

.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탭 밑줄 스크롤 동기화

헤더 고정 css 안에서 밑줄이 탭을 따라간다

헤더 안에 나란히 놓인 앵커 탭들 아래로 밑줄 하나가, 지금 지나가는 섹션에 맞춰 옆 탭으로 미끄러져 갑니다.

탭 밑줄 스크롤 동기화
새 창에서 열기

03 · 탭 밑줄 스크롤 동기화 — 측정 누적면적 0.308% · 움직인 프레임 14/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

translateX 고정폭@keyframes tus-loopflex:0 0 auto

지금 섹션을 찾는 방법

실제 페이지에서는 IntersectionObserver로 통과 중인 섹션을 찾아 그 탭의 위치로 밑줄을 옮깁니다.

// 탭 폭이 서로 달라 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검색 바 흡수

검색 줄이 헤더로 합쳐진다

헤더 아래 따로 떠 있던 검색 입력 줄이 스크롤하면 위로 미끄러져 메인 헤더 한 줄 안으로 합쳐집니다.

검색 바 흡수
새 창에서 열기

04 · 검색 바 흡수 — 측정 누적면적 15.596% · 움직인 프레임 12/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

2행→1행translateY 합치기높이 트랜지션

01과 반대 방향의 층 겹치기

두 줄을 고정 높이 슬롯에 겹쳐 두고 검색 줄만 헤더 높이만큼 translateY로 끌어올리는 방식이라, 01번과 같은 층 겹치기 구조를 반대 방향으로 씁니다.

.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검색창 확장

아이콘이 입력창으로 넓어진다

돋보기 아이콘 하나였던 자리가 클릭하면 폭이 넓어지며 실제 입력창으로 바뀝니다.

검색창 확장
새 창에서 열기

05 · 검색창 확장 — 측정 누적면적 3.736% · 움직인 프레임 13/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

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브레드크럼 등장

빈 자리에 경로가 뜬다

로고 옆 비어 있던 자리에, 스크롤하는 순간 지금 보고 있는 위치 경로가 페이드와 슬라이드로 나타납니다.

브레드크럼 등장
새 창에서 열기

06 · 브레드크럼 등장 — 측정 누적면적 13.451% · 움직인 프레임 11/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

opacity+translateX페이지 위치 표시순수 CSS

움직임은 전부 CSS 가 맡는다

자바스크립트는 클래스 하나만 붙이고, 실제 움직임(opacity·translateX)은 전부 CSS transition이 맡습니다.

.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유저 메뉴 압축

이름이 접혀 아바타만 남는다

우측 아바타 옆에 붙어 있던 사용자 이름 글자가 스크롤하면 폭과 함께 접혀 아바타 아이콘만 남습니다.

유저 메뉴 압축
새 창에서 열기

07 · 유저 메뉴 압축 — 측정 누적면적 0.298% · 움직인 프레임 12/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

max-width 축소텍스트 clip아바타만 유지

margin-right 도 함께 줄인다

02번(아이콘만 남는 내비)과 같은 max-width 접기 기법을 오른쪽 정렬 요소에 적용한 형태라, margin-right도 함께 0으로 줄여야 아바타가 실제로 오른쪽 끝에 붙습니다.

.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 버튼 슬라이드인

한참 내려야 버튼이 들어온다

본문을 일정 구간 이상 내려야 헤더 오른쪽 끝에 구매·문의 버튼이 화면 밖에서 미끄러져 들어와 자리 잡습니다.

CTA 버튼 슬라이드인
새 창에서 열기

08 · CTA 버튼 슬라이드인 — 측정 누적면적 14.556% · 움직인 프레임 12/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

translateX(100%)→0스크롤 임계값transform만

임계값이 가장 큰 이유

아홉 개 중 스크롤 임계값이 가장 커서(120px), 페이지에 발을 들이자마자 버튼부터 들이대지 않습니다.

.head__cta {
  transform: translateX(140%);
  transition: transform .3s ease-spring;
}

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

09알림 뱃지 등장

점이 튀어나왔다 맥박친다

스크롤이 80px을 넘는 순간 헤더 아이콘 위 알림 점이 튀어나오듯 커졌다가, 그 뒤로는 은은하게 맥박치기를 반복합니다.

알림 뱃지 등장
새 창에서 열기

09 · 알림 뱃지 등장 — 측정 누적면적 0.449% · 움직인 프레임 17/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

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 헤더 고정, 어디서 깨지는가

짐작한 translateX 는 늘 틀린다

가장 자주 걸려 넘어지는 건 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번과 겹침 순서

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

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

전환 시간만 없앤다

prefers-reduced-motion: reduce가 켜진 사람에게 이 아홉 개는 서로 조금씩 다르게 반응합니다. 01·02·04·06·07·08번은 transition: none으로 전환 시간만 없애서, 실제 페이지에서 스크롤을 넘기면 배너가 접히고 라벨이 줄고 이름이 사라지는 결과 자체는 그대로 일어나되 순간적으로 바뀝니다 — 기능은 지키고 애니메이션만 뺀 것입니다.

09번만 다르게 처리했다

09번(알림 뱃지)만 다르게 처리했는데, 전환뿐 아니라 스크롤 이후에도 계속 반복되는 dot-pulse-real 맥박 자체를 꺼서 점이 한 번 나타난 뒤로는 완전히 정지합니다 — 끝없이 반복되는 애니메이션은 전환과 달리 그 자체가 움직임이라 따로 꺼야 합니다. 03번(탭 밑줄)과 05번(검색창)은 애초에 스크롤에 자동으로 반응하지 않으니 줄일 "전환"도 사용자가 클릭한 순간에만 있고, 그 전환의 시간만 없앱니다.

더 읽을 거리

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

FAQ — 자주 묻는 질문

네비게이션 바 고정 효과는 아홉 개 전부 스크롤해야 나타나나요?

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

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

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

vanilla와 react 코드가 다른가요?

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

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

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