CSS Shrinking Header on Scroll

A sticky header that shrinks its height and text on scroll using CSS scroll-driven animations, with no JavaScript.

Published August 30, 2026 Intermediate 8 min read

A shrinking header is a bar that starts tall and compacts as the reader scrolls, giving the content back the room the branding was using. Scroll-driven animations do it with no script at all: animation-timeline: scroll() ties an animation's progress to a scroll position instead of to a clock, so the header's height is a function of how far the page has moved rather than something recalculated in a scroll listener.

Two more versions follow. One adds a reading progress bar that reads the same timeline over a different range, showing that several elements can share one scroll without any of them knowing about the others. The other builds the same effect with no scroll-driven animation at all, using a tall block that scrolls away above a sticky bar, which works in every browser that supports position: sticky.

Scroll inside the frame

↓ scroll to shrink the header

HTML

<div class="shrink-frame">
  <header class="shrink-header">
    <span class="shrink-logo">acme.</span>
    <nav class="shrink-nav">
      <a href="#">Home</a>
      <a href="#">Work</a>
      <a href="#">About</a>
    </nav>
  </header>
  <div class="shrink-content">
    <div class="shrink-hint">↓ scroll to shrink the header</div>
    <div class="shrink-block"></div>
    <div class="shrink-block-tall"></div>
    <div class="shrink-block"></div>
    <div class="shrink-block-tall"></div>
    <div class="shrink-block"></div>
    <div class="shrink-block-tall"></div>
    <div class="shrink-block"></div>
  </div>
</div>

CSS

/* The scroller needs a real height, or scroll() has nothing to track. */
.shrink-frame {
  width: 100%;
  height: 320px;
  overflow-y: auto;
  border: 1px solid #2a2a2d;
  border-radius: 12px;
}

.shrink-header {
  position: sticky;
  top: 0;
  z-index: 10;
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 0 1.25rem;
  background: #141415;
  border-bottom: 1px solid #2a2a2d;
  animation: sh-shrink linear both;
  animation-timeline: scroll();
  animation-range: 0px 80px;
}

@keyframes sh-shrink {
  from { height: 60px; }
  to   { height: 38px; }
}

.shrink-logo {
  font-family: "DM Mono", "Fira Code", Consolas, monospace;
  font-weight: 700;
  color: #b8ff57;
  animation: sh-logo linear both;
  animation-timeline: scroll();
  animation-range: 0px 80px;
}

@keyframes sh-logo {
  from { font-size: 1rem; }
  to   { font-size: .72rem; }
}

.shrink-nav {
  display: flex;
  gap: 1.25rem;
}

.shrink-nav a {
  font-family: "DM Mono", "Fira Code", Consolas, monospace;
  color: #88888f;
  text-decoration: none;
  animation: sh-nav linear both;
  animation-timeline: scroll();
  animation-range: 0px 80px;
}

@keyframes sh-nav {
  from { font-size: .72rem; }
  to   { font-size: .58rem; }
}

.shrink-content {
  padding: 1rem 1.25rem 1.25rem;
  display: flex;
  flex-direction: column;
  gap: .75rem;
}

.shrink-hint {
  font-family: "DM Mono", "Fira Code", Consolas, monospace;
  font-size: .65rem;
  color: #88888f;
  letter-spacing: .08em;
  text-align: center;
  padding: .25rem 0;
}

.shrink-block,
.shrink-block-tall {
  background: #1c1c1e;
  border: 1px solid #2a2a2d;
  border-radius: 8px;
}

.shrink-block { height: 52px; }
.shrink-block-tall { height: 76px; }

Other ways to build it

One timeline, two ranges

The header and the progress bar both declare animation-timeline: scroll() and both resolve to the same scroller, so they run off one source without either knowing the other exists. What separates them is animation-range. The header finishes shrinking in the first 80 pixels, while the bar has no range set, which means it spans the whole scrollable distance. The bar animates transform: scaleX() rather than width, so it composites instead of forcing layout.

scroll: the header shrinks in 80px, the bar tracks the whole way

HTML

<div class="prog-frame">
  <header class="prog-header">
    <span class="prog-logo">acme.</span>
    <nav class="prog-nav">
      <a href="#">Home</a>
      <a href="#">Work</a>
      <a href="#">About</a>
    </nav>
    <span class="prog-bar"></span>
  </header>
  <div class="prog-content">
    <div class="prog-hint">scroll: the header shrinks in 80px, the bar tracks the whole way</div>
    <div class="shrink-block"></div>
    <div class="shrink-block-tall"></div>
    <div class="shrink-block"></div>
    <div class="shrink-block-tall"></div>
    <div class="shrink-block"></div>
    <div class="shrink-block-tall"></div>
    <div class="shrink-block"></div>
  </div>
</div>

CSS

/* the guard matters: without it, a browser with no
   scroll timeline runs these as ordinary animations
   and plays them once on load */
@supports (animation-timeline: scroll()) {
  .prog-header {
    animation: ph-shrink linear both;
    animation-timeline: scroll();
    animation-range: 0px 80px;
  }

  /* same timeline, no range, so it spans the whole scroll */
  .prog-bar {
    animation: ph-progress linear both;
    animation-timeline: scroll();
  }
}

@keyframes ph-shrink {
  from { height: 60px; }
  to   { height: 38px; }
}

/* scaleX composites; animating width would not */
@keyframes ph-progress {
  from { transform: scaleX(0); }
  to   { transform: scaleX(1); }
}

.prog-bar {
  position: absolute;
  left: 0;
  bottom: -1px;
  width: 100%;
  height: 2px;
  background: #b8ff57;
  transform: scaleX(0);
  transform-origin: left center;
}

The same effect with no scroll timeline at all

Before scroll-driven animations existed, this was how it was done, and it still works everywhere position: sticky does. A tall block sits above a short bar, and the bar is the sticky one. Scrolling takes the tall block away and leaves the compact bar pinned, so the header appears to shrink without anything being animated. The trade is that there is no intermediate state: the header is tall, then it is short, with the transition being the tall part scrolling off rather than the bar changing size.

acme. Design studio, est. 2011

HTML

<div class="fb-frame">
  <div class="fb-hero">acme.</div>
  <header class="fb-bar">...</header>
  <div class="fb-content">...</div>
</div>

CSS

.fb-frame {
  height: 320px;
  overflow-y: auto;
}

/* the tall part is ordinary flow content and
   simply scrolls away */
.fb-hero {
  display: flex;
  flex-direction: column;
  justify-content: center;
  height: 96px;
  padding: 0 1.25rem;
}

/* the short bar is the sticky one, so what is left
   behind after the hero scrolls off is the compact
   header. Nothing is animated. */
.fb-bar {
  position: sticky;
  top: 0;
  z-index: 5;
  display: flex;
  align-items: center;
  justify-content: space-between;
  height: 40px;
  padding: 0 1.25rem;
  background: #141415;
  border-bottom: 1px solid #2a2a2d;
}

How it works

animation-timeline: scroll() links the animation to the nearest scroll container. animation-range: 0px 80px means the full shrink plays over the first 80px of scroll, so the header is compact well before the reader reaches the content. Multiple elements share the same scroll timeline and shrink in step. No JavaScript, no scroll listeners, no class toggling.

A normal animation runs against time. A scroll-driven animation runs against distance, and every property of the animation still applies: linear keeps the mapping proportional, and both as the fill mode holds the from state before the range starts and the to state after it ends. Drop the fill mode and the header snaps back to its unstyled height the moment the scroll passes the end of the range.

scroll() with no arguments means scroll(nearest block), which is the nearest scrolling ancestor along the block axis. That word nearest is the detail that catches people out. Any ancestor with overflow set to something other than visible is a scroller, so a wrapper added to clip a shadow can quietly become the timeline source, and the header then tracks a container that never scrolls. When the element is not inside the scroller it needs to track, a named timeline is the answer, and that is what scroll spy is built on.

animation-range: 0px 80px maps the whole animation onto the first 80 pixels of scroll. Without it the range is the entire scrollable distance, so the header would still be halfway through shrinking at the middle of a long article. Ranges can also be given in percentages of the scroll length, which is useful for a progress indicator and wrong for a header, since a header should compact after a fixed distance regardless of how long the page is.

Animating height and font-size costs layout on every frame, and this is the one place where scroll-driven animation is easy to overuse. The browser cannot composite a height change, so it recalculates layout for the header and everything after it. On a header with three children it is not measurable. Applied to a large tree it will show. transform: scale() composites instead, at the cost of scaling the text along with the box.

The header is also position: sticky with top: 0, and that is a separate mechanism from the animation. Sticky keeps it visible; the timeline changes its shape. Remove the sticky and the header shrinks while scrolling out of view, which is not useful. The rules that make sticky work, and the ways it silently fails, are covered on sticky sidebar.

CSS properties used

animation-timeline
scroll() replaces the document clock with a scroll position. With no arguments it means the nearest scrolling ancestor along the block axis.
animation-range
Which slice of the scroll the animation is mapped onto. 0px 80px finishes the shrink within the first 80 pixels.
animation-fill-mode
both holds the start state before the range and the end state after it. Without it the header reverts once the range is passed.
position
sticky with top: 0 keeps the header on screen. The animation only changes its shape; staying visible is a separate job.
height
The animated property in the keyframes. It forces layout on every frame, which is fine on a small header and expensive on a large one.
z-index
The header has to be raised above the content scrolling underneath it, or the content is painted over the pinned bar.

Browser support

FeatureChromeFirefoxSafariEdge
position: sticky91597.191
animation455.112
@supports2822912
prefers-reduced-motion746310.179

Scroll-driven animations have no Can I Use entry, so no version numbers for animation-timeline appear above and none should be quoted from memory. Support is uneven across engines and still moving, so check the current state before shipping. The important part is that it fails softly: a browser that does not understand animation-timeline ignores the declaration and runs the animation on the ordinary time-based clock, which is why animation-play-state: paused or a @supports (animation-timeline: scroll()) wrapper belongs around any scroll-driven rule. Without one, a browser without support plays the shrink once on load as a plain animation, and the header ends up compact before the reader has scrolled anywhere.

Accessibility notes

A header that changes size while the reader scrolls is motion, and prefers-reduced-motion: reduce should switch it off. The clean way is to give the header its compact height outright and drop the animation, so the layout is stable rather than animated at zero duration.

Whatever height the header settles at, in-page links land underneath it, because the browser scrolls a fragment target flush to the top of the scroller. scroll-margin-top on the headings, set to the compact height rather than the tall one, puts the landing point clear of the bar. The same applies to keyboard focus moving into content near the top.

Do not shrink the header past the point where its controls are still usable. A navigation link in a 38 pixel bar is fine; the same link in a 24 pixel bar has a touch target below any reasonable minimum, and a reader with an imprecise pointer will miss it.

Keep the accessible name of the logo and the links stable as the header compacts. Swapping full text for initials at a smaller size is a visual decision that must not remove the text a screen reader announces, so hide the long form visually rather than replacing it.

What you can build with it

  • Content sites. A tall masthead on arrival that compacts to a slim bar for the rest of the article, which is the case this pattern exists for.
  • Documentation. A header that gives up its height so a sticky sidebar and the prose both have more room below it.
  • Product pages. Branding at full size at the top, then a compact bar carrying the product name and the buy button once the reader is into the detail.
  • Dashboards. A page title that shrinks to leave room for a wide table, without the table needing to know anything about the header.
  • Landing pages. A transparent header over a hero image that gains a solid background as the hero scrolls away, animated over the same range.

Mistakes worth avoiding

  • Writing a scroll-driven rule with no @supports guard. A browser without animation-timeline runs the keyframes on the ordinary clock, so the header shrinks once on load and stays compact whether or not anything has been scrolled.
  • Leaving animation-range off. The default range is the whole scrollable distance, so on a long page the header is still shrinking a thousand pixels down.
  • Forgetting animation-fill-mode: both. Once the scroll passes the end of the range the animation is no longer applying, and the header springs back to its full height mid-article.
  • Assuming scroll() tracks the page. It tracks the nearest scrolling ancestor, so a wrapper with overflow: hidden between the header and the page silently becomes the timeline source and the header never moves.
  • Animating height on a header containing a large subtree. Every frame forces layout for everything below it, and the scroll starts to feel heavy on a mid-range phone.

Frequently asked questions

How do I shrink a header on scroll without JavaScript?
Give the header position: sticky; top: 0, then attach an animation whose keyframes change its height and set animation-timeline: scroll(). Add animation-range: 0px 80px so the shrink completes early, and animation-fill-mode: both so the end state is held afterwards.
Why does my header shrink before I scroll anything?
The browser does not support animation-timeline, so it ignored that line and ran the keyframes as a normal time-based animation, which plays immediately. Wrap the scroll-driven rules in @supports (animation-timeline: scroll()) so they only apply where the timeline exists.
What does scroll() with no arguments mean?
It is short for scroll(nearest block): the nearest ancestor that scrolls, measured along the block axis. Any ancestor with overflow other than visible qualifies, which is why an unrelated wrapper can end up being the timeline source.
Can several elements share one scroll timeline?
Yes. Each one declares animation-timeline: scroll() and they all resolve to the same scroller, so they move in step without knowing about each other. They can use different animation-range values on the same timeline, which is how a progress bar spanning the whole page coexists with a header that finishes in 80 pixels.
Is this better than a scroll event listener?
For this effect, yes. A listener runs script on the main thread for every scroll event and then writes styles, which is where scroll jank comes from. A scroll-driven animation is declared once and evaluated by the browser. The remaining cost is whichever property you animate, and height is not a cheap one.