GODRICH

9 CSS Sticky Header Extras — Search, CTA, Badge

A CSS sticky header keeps a page's menu bar pinned to the top while everything below scrolls past.

Auto-plays · click a tile to jump to its section · all nine in one zip

The nine aren't ranked by popularity — they're grouped by what actually changes inside the header. Three fold something down to save space (01, 02, 07), two move or reveal position information as you scroll (03, 06), two widen or merge search UI into the header (04, 05), and the last two bring in an element that wasn't there before (08, 09).

01Announcement bar collapse

The promo bar pinned above the header disappears upward the instant you start scrolling, without ever pushing the layout around, and the logo row rises to fill the space it leaves behind. Both pieces sit in the same absolutely-positioned slot and only their transform changes, which is why nothing jumps or reflows while the bar is vanishing.

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

// in production: once scrolling starts, the bar slides up and the header rises into its place
.ahc.is-scrolled {
  .ahc__bar { transform: translateY(-100%); opacity: 0; }
  .ahc__head { transform: translateY(-32px); }
}

02Icon-only nav collapse

Nav link labels shrink away width-first as you scroll, leaving only their icons behind so a narrow header doesn't run out of room. Fading the opacity alone would leave blank space where the text used to be, so max-width has to collapse to zero too before the icons actually slide left.

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

// in production: labels collapse width-first on scroll, leaving only icons
.ioc.is-scrolled .ioc__label { max-width: 0; transform: scaleX(0); opacity: 0; }

03Tab underline synced to scroll

A single underline beneath a row of anchor tabs slides over to whichever tab matches the page section currently in view. Because the tabs aren't the same width, the underline can't jump to a guessed pixel value — the real page reads each tab's offsetLeft and offsetWidth while IntersectionObserver decides which tab is active.

translateX 고정폭@keyframes tus-loopflex:0 0 auto
// tab widths differ, so the underline reads offsetLeft/offsetWidth at runtime — never a guessed 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)';
}

04Search bar merges into header

A search row that normally floats below the header slides upward and merges into the header's own row once you start scrolling. The two rows sit in a fixed-height slot exactly like item 01, just stacked the opposite way, with the search row traveling the header's full height to land on top.

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

// in production: once scrolling starts, the search row slides up into the header row
.sbm.is-scrolled .sbm__search { transform: translateY(-56px); }

05Search icon expands to field

A single magnifier icon widens out on click and turns into a real text field in the same spot. It's the only one of the nine that has nothing to do with scrolling at all — the trigger is a click (and the focus that follows), not a scroll position.

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

06Breadcrumb reveal on scroll

The empty space beside the logo fades and slides in a breadcrumb trail the moment you scroll. The JavaScript only toggles a single class; the fade and the slide themselves are handled entirely by a 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;
}

// in production: the JS only toggles a class, the fade and slide are pure CSS transitions
.head.is-scrolled .head__crumb { opacity: 1; transform: translateX(0); }

07User menu compacts to avatar

The username sitting next to the avatar on the right collapses width-first as you scroll, leaving only the avatar icon behind. It's the same max-width collapse technique as item 02 turned around for a right-aligned element, and margin-right has to shrink along with it or the avatar won't actually reach the edge.

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 button slides in

Only after you scroll past a set point does a buy or contact button slide in from off-screen to the header's right edge. Its 120px threshold is the highest of the seven scroll-triggered items, so the button doesn't shove itself at someone who just landed on the page.

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

// in production: only past 120px of scroll does the button slide in from off-screen
.head.is-scrolled .head__cta { transform: translateX(0); }

09Notification dot pulse

The instant scroll passes a threshold, a notification dot pops onto a header icon and then keeps pulsing gently on a loop. The pop-in runs on a plain CSS transition while the ongoing pulse is a separate @keyframes animation, which is what lets the pulse be switched off on its own later.

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

Where CSS sticky headers break

The trap most people hit first is a sliding indicator like item 03. When tabs aren't the same width, it's tempting to hardcode a translateX guess for each one, but unless every tab happens to measure exactly the same, that guess is wrong the moment a label gets longer or shorter. This demo instead fixes each tab's width to its own content with flex: 0 0 auto, then reads offsetLeft and offsetWidth at runtime to compute where the underline should land — real numbers, not guesses, which is how it lands at 18.73px, 87.54px, and 156.35px for this particular row. Reproduce the mistake yourself in plain CSS by giving the tabs flex: 1 1 0 (an equal share of the row) while leaving the underline's positions fixed, and it falls short under the narrow tabs while overshooting the wide one; open the 03 folder and change a tab's label length to see the real version recalculate instead of drifting.

The second thing worth watching is item 05 (search icon expands to field): despite sitting in a sticky-header roundup, it ignores scroll completely and opens only on a click, so anyone hunting through its code for a scroll listener will come up empty. Item 04's overlapping-layer trick needs one line most people skip — leave the z-index off the absolutely-positioned search row and it stacks on top of the logo instead of sliding cleanly beneath it. The zip holding all nine files opens with weqydbxa, and once it's unzipped you'll find the same vanilla/ and react/ layout laid out side by side for comparison.

Accessibility — what reduced-motion does to each of the nine

Under prefers-reduced-motion: reduce, these nine don't all respond the same way. Items 01, 02, 04, 06, 07, and 08 lose only the transition's timing (transition: none) — scroll past the same trigger point in production and the bar still collapses, the label still shrinks, the name still disappears, just instantly instead of eased, so the behavior survives and only the animation is gone. Item 09 (notification dot) goes further: on top of dropping the transition, it also kills the dot-pulse-real animation itself, so once the dot appears it stops there and never pulses again — an animation that loops forever is itself motion, unlike a one-off transition, and has to be switched off separately. Items 03 (tab underline) and 05 (search expand) never had a scroll trigger to reduce in the first place, so reduced-motion only shortens the transition that fires at the moment of a click or a focus, not anything tied to scrolling.

For headers that fold and shade on their own without any of this internal movement, 9 CSS Sticky Header Effects — No JS, Scroll-Driven covers the base set this one builds on. If scroll effects beyond headers are more what you're after, 9 CSS Animation on Scroll Effects, No JavaScript is a related roundup, and the CSS category hub has the rest. The scroll-container rules behind position: sticky are documented on MDN, and the viewport-detection API item 03 relies on is covered in the MDN IntersectionObserver docs.

FAQ — Frequently Asked Questions

Do all nine need scrolling to move?

No. Seven of them (01, 02, 04, 06, 07, 08, 09) react to a scroll position — a threshold somewhere between 20px and 120px — and item 03 reacts to which section is currently visible, but item 05 (search icon expands to field) ignores scroll entirely and only opens on click.

Why transform instead of height when a banner and a header overlap?

Animating height or margin forces the browser to recompute layout on every frame, which is where the stutter comes from. Stacking the two layers with absolute positioning and moving only transform: translateY() changes a composited layer instead, so it stays smooth — items 01 and 04 both use this trick.

Does the vanilla code differ from the React version?

Vanilla and React render the same motion values. The difference lives only in the trigger — the vanilla demo loops nonstop under an is-demo class for the gallery, while the React component listens for a real scroll event (or, for item 03, an IntersectionObserver) and swaps the class only when the condition is actually met.

Enter the archive password

The password is inside this article. You will find it as you read.