CSS & HTML

How to Zoom a Hero Image on Scroll with CSS

By Vail Joy Updated 5 min read

Put the image in a clipped container and animate its transform: scale() with a scroll-driven animation: animation-timeline: scroll(root) ties the animation’s progress to the page’s scroll position. That is about ten lines of CSS and no scroll listener.

The original version of this tutorial used jQuery to read scrollTop on every scroll event and rewrite the image width. That ran on the main thread, forced layout on every event and tended to stutter. The version below is shorter, smoother and easy to switch off for people who ask for less motion.

What the finished effect does

The hero is a fixed-height box with overflow: hidden. As you scroll the first 70% of the viewport height, the image inside grows from its normal size to 135%, so it feels like you are slowly moving into the photo. The heading stays put on top.

A full-width hero image of a mountain coast at sunset with the heading Northern Coast, before any scrolling
The hero at scroll position 0: the image at its natural scale.
The same hero after scrolling 300 pixels: the mountains are noticeably larger and the hero has moved up, so article text now shows below it
After scrolling 300px the same image is scaled up by roughly 22%.

1. The markup

Use a real <img> rather than a CSS background. You get alt text when the image carries meaning, srcset and loading control, and object-fit for cropping. If the photo is purely decorative, leave alt="".

<header class="hero">
  <img src="coast.avif" width="1600" height="900" alt="">
  <h1>Northern Coast</h1>
</header>

Keep the hero image eager-loaded (the default) and consider fetchpriority="high", since it is usually your largest contentful paint element. Do not lazy-load it.

2. The layout CSS

The old padding-bottom percentage trick is no longer needed. Give the hero a height, clip it, and make the image fill it with object-fit: cover.

.hero {
  position: relative;
  height: 70vh;
  min-height: 420px;
  overflow: hidden;
  display: grid;
  place-items: end start;
  background: #12202f; /* shows while the image loads */
}

.hero img {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
}

.hero h1 {
  position: relative; /* stacks above the image */
  margin: 0;
  padding: 2rem;
  color: #fff;
}

Because the container clips the overflow, scaling the image never creates horizontal scrolling or pushes the layout around. Scaling with transform also avoids layout and paint work that changing width or background-size would trigger.

3. Drive the zoom from scroll

Define the keyframe, then attach it to a scroll timeline instead of to time:

@keyframes hero-zoom {
  to { transform: scale(1.35); }
}

@media (prefers-reduced-motion: no-preference) {
  .hero img {
    animation: hero-zoom linear both;
    animation-timeline: scroll(root block);
    animation-range: 0 70vh;
  }
}

Three things are doing the work:

  • animation-timeline: scroll(root block) uses the root scroller’s vertical scroll progress as the clock.
  • animation-range: 0 70vh says the animation starts at the top of the page and finishes after 70vh of scrolling, which matches the hero height, so the zoom completes as the hero leaves the screen.
  • linear easing keeps the zoom proportional to how far the person has scrolled. An eased curve would make it feel disconnected from their finger or wheel.

Note that the shorthand animation must come before animation-timeline, because the shorthand resets the timeline.

Variation: zoom relative to the element, not the page

If the hero is not at the top of the page, such as a banner midway down an article, use a view() timeline, which tracks the element’s own position in the viewport:

.banner img {
  animation: hero-zoom linear both;
  animation-timeline: view();
  animation-range: entry 0% exit 100%;
}

Now the zoom starts when the banner first peeks in at the bottom edge and ends when it has fully left at the top. You can also reverse the effect by animating from scale(1.35) down to scale(1) for a “settle into place” feel.

Browser support and a fallback

Scroll-driven animations have shipped in Chrome and Edge since version 115 and in Safari since 26 (see MDN’s animation-timeline page and the Chrome documentation). MDN’s compatibility data lists Firefox 159 as the first version with support, and that release is only just arriving as I write this (late October 2026). MDN and the web-platform status data therefore still mark the feature as limited availability rather than Baseline, so plan for some visitors, especially on older Firefox versions, to get no zoom.

The effect is decoration, so the right strategy is progressive enhancement. In an unsupported browser the declaration is ignored and the image simply stays at its normal size, which is a perfectly good hero. If you want the zoom everywhere, wrap the CSS in a feature query and add a small script for the rest:

.hero img {
  transform: scale(calc(1 + var(--zoom, 0) * 0.35));
}

@supports (animation-timeline: scroll()) {
  @media (prefers-reduced-motion: no-preference) {
    .hero img {
      transform: none;
      animation: hero-zoom linear both;
      animation-timeline: scroll(root block);
      animation-range: 0 70vh;
    }
  }
}
const hero = document.querySelector('.hero');
const canDrive = CSS.supports('animation-timeline', 'scroll()');
const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;

if (hero && !canDrive && !reduce) {
  let queued = false;
  const update = () => {
    queued = false;
    const progress = Math.min(Math.max(scrollY / (innerHeight * 0.7), 0), 1);
    hero.style.setProperty('--zoom', progress);
  };
  addEventListener('scroll', () => {
    if (!queued) { queued = true; requestAnimationFrame(update); }
  }, { passive: true });
  update();
}

The script only runs where the CSS timeline is missing, uses a passive listener and batches work with requestAnimationFrame, and writes one custom property that the same transform reads. I tested the CSS path in Chromium with an 800px-tall window, where 70vh is 560px: the computed scale was 1 at the top and 1.25 after 400px of scrolling, which is what 1 + 0.35 × 400 / 560 predicts.

Accessibility and performance checklist

  • Respect reduced motion. The prefers-reduced-motion: no-preference wrapper means people who ask their system for less motion get a still image. The script does the same check.
  • Keep the text readable at every scale. The heading is a separate element above the image, so it does not scale. Make sure its contrast holds across the whole photo, using a darker overlay or text shadow if needed.
  • Keep the zoom modest and the source sharp. A scale between 1.1 and 1.4 reads as depth; beyond that it feels gimmicky and can bother some visitors. At 135% a 1600px-wide image is stretched on large screens, so serve a 2400px AVIF through srcset and sizes.
  • Animate only transform. It can run on the compositor, so the effect stays smooth even when the main thread is busy.

Where to go next

If you are comfortable with the pieces above, these Web Designer Wall tutorials fill in the rest of the toolkit: Easy CSS Animation Using @keyframes covers the keyframe syntax, and Easy CSS Animation With Transition & Transforms explains the transforms used here. For a ready-made hero to try the effect on, see the Bootstrap 5 hero header with search bar or the hero search box with image background. Once you have the zoom working, the 9 simple CSS image filters are an easy way to add a matching tint or blur, and the category image cards with zoom hover show the same idea on hover instead of scroll.

7 comments from the archive
    1. Hi Don!

      The first method will be more responsive than the second (as background fills are not inherently responsive and may need more care). You can see in the codepen window vs full screen that it works at various widths. Please also note that these are intended as instructional tools, not project drop-ins, so may not include things like a full set of media queries. I do see folks struggle with responsive backgrounds often, so we’ll see about writing up a tutorial on that in the near future!

    1. This is another great way to handle the scaling in the Javascript snippet! The method to use depends most on the implementation and any restrictions in place. In general, I agree that your method is more fluid. Thank you for sharing!

  1. Hi. This is great, thanks for the tip. How do I make the the image zoom less for a more subtle effect?

  2. Two things:
    1. Hero Images may be “aesthetically pleasing” to the designer – but they get in the way of the Visitor accessing the real content – or doing real work, by making the Visitor do extra work (i.e. scroll) .

    How can I make my browser block “hero images” ?

    2. ANY animation is distracting / annoying.
    Quite the opposite of “engaging”, zoom on scroll makes me want to go elsewhere.

    Again, how can I make my browser block these unwanted intrusions.

    Thanks

Comments are closed on this article.