CSS Scroll-Driven Animations: No JavaScript, No Libraries, No Jank

CSS Scroll-Driven Animations: No JavaScript, No Libraries, No Jank

Scroll Animations Without the Scroll Event

Scroll-linked effects — progress bars, fade-in reveals, parallax backgrounds, sticky header transitions — have always required JavaScript. You’d listen to the scroll event, calculate positions with getBoundingClientRect(), apply transforms, and wrestle with requestAnimationFrame() to avoid jank. Libraries like GSAP ScrollTrigger and Intersection Observer APIs made this bearable but never simple.

CSS scroll-driven animations eliminate all of that. You write a standard @keyframes animation, then link it to scroll progress instead of time. The animation runs on the compositor thread — off the main thread — so it’s inherently jank-free. No JavaScript. No layout thrashing. No performance anxiety.

Browser support is solid: Chrome, Edge, Safari, and recent Firefox versions all support the core API.

Two Types of Scroll Timelines

CSS gives you two timeline functions, each serving a different purpose:

scroll() — Container Scroll Progress

Links the animation to how far a scroll container has been scrolled. At 0% scroll, the animation is at its start. At 100% scroll, it’s at its end.

The classic use case: a reading progress bar at the top of the page.

view() — Element Visibility Progress

Links the animation to an element’s position as it moves through the viewport. The animation starts when the element enters the viewport and ends when it leaves.

The classic use case: fade-in or slide-up effects as content scrolls into view.

Building a Reading Progress Bar

This is the simplest scroll-driven animation and a perfect introduction to the API.

<div class="progress-bar"></div>
@keyframes grow-width {
  from { transform: scaleX(0); }
  to   { transform: scaleX(1); }
}

.progress-bar {
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: 4px;
  background: #4f46e5;
  transform-origin: left;

  /* Link the animation to page scroll */
  animation: grow-width linear;
  animation-timeline: scroll();
}

That’s it. The bar grows from 0% to 100% width as the user scrolls down the page. No JavaScript, no scroll event listener, no requestAnimationFrame(). The browser handles everything on the compositor thread.

The scroll() function defaults to the nearest scroll ancestor. You can target a specific container: scroll(root) for the viewport, scroll(nearest) for the closest scrollable parent, or scroll(self) for the element itself.

Reveal-on-Scroll Animations

The view() timeline makes scroll-triggered reveals trivial. Each element animates based on its own position in the viewport.

@keyframes slide-up {
  from {
    opacity: 0;
    transform: translateY(40px);
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
}

.reveal-card {
  animation: slide-up ease-out both;
  animation-timeline: view();
  animation-range: entry 0% entry 100%;
}

Every element with the reveal-card class will fade and slide up as it enters the viewport. The animation-range property controls exactly when the animation plays:

  • entry 0% — the element’s top edge reaches the bottom of the viewport
  • entry 100% — the element is fully inside the viewport
  • exit 0% — the element starts leaving the viewport at the top
  • exit 100% — the element is completely out of view

You can also use cover (the entire time the element is visible), contain (only while fully visible), or percentage ranges like entry 10% entry 80%.

Parallax Effect

Creating a parallax background effect — where the background moves slower than the foreground — is surprisingly clean:

@keyframes parallax-shift {
  from { transform: translateY(-20%); }
  to   { transform: translateY(20%); }
}

.hero-background {
  animation: parallax-shift linear;
  animation-timeline: scroll();
}

.hero-section {
  position: relative;
  overflow: hidden;
  height: 80vh;
}

.hero-background {
  position: absolute;
  inset: -20% 0;
  background: url('hero-bg.jpg') center / cover;
}

The background image shifts by 40% total (from -20% to +20%) as the page scrolls. The inset: -20% 0 gives extra room so the image doesn’t reveal blank space during the shift.

Sticky Header Shrink

A header that shrinks as you scroll past the hero section:

@keyframes shrink-header {
  from {
    padding-block: 24px;
    font-size: 1.25rem;
    background: transparent;
  }
  to {
    padding-block: 8px;
    font-size: 1rem;
    background: rgba(255, 255, 255, 0.95);
    backdrop-filter: blur(10px);
    box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
  }
}

.site-header {
  position: sticky;
  top: 0;
  z-index: 100;

  animation: shrink-header linear both;
  animation-timeline: scroll();
  animation-range: 0px 300px;
}

The animation-range: 0px 300px means the animation completes within the first 300 pixels of scroll. After that, the header stays in its shrunken state.

Multiple Animations on One Element

You can combine multiple scroll-driven animations on a single element, each with its own timeline:

@keyframes fade-in { from { opacity: 0; } to { opacity: 1; } }
@keyframes scale-up { from { transform: scale(0.8); } to { transform: scale(1); } }

.animated-card {
  animation: fade-in ease-out, scale-up ease-out;
  animation-timeline: view(), view();
  animation-range: entry 0% entry 60%, entry 20% entry 80%;
}

The fade starts immediately on entry and completes at 60%. The scale starts slightly later (20%) and ends at 80%. This creates a staggered effect where the element fades in first, then scales up — all driven by scroll position.

Graceful Degradation

Since this is a CSS feature, unsupported browsers simply don’t play the animation. The element stays in its default state, which is usually fine for decorative effects. For critical functionality, use @supports:

/* Default: visible, no animation */
.reveal-card {
  opacity: 1;
  transform: none;
}

/* Only apply animation if supported */
@supports (animation-timeline: view()) {
  .reveal-card {
    animation: slide-up ease-out both;
    animation-timeline: view();
    animation-range: entry 0% entry 100%;
  }
}

This prevents the element from being invisible in browsers that don’t support scroll-driven animations.

Common Mistakes

1. Forgetting animation-range

Without animation-range, a view() animation plays across the element’s entire journey through the viewport — from entering at the bottom to exiting at the top. That’s usually too much range, making the animation feel sluggish. Set a tight range for snappy effects.

2. Animating Layout Properties

Stick to transform and opacity for animations. Animating width, height, padding, or margin triggers layout recalculation, which defeats the purpose of running on the compositor thread.

3. Not Testing at Different Scroll Speeds

Scroll-driven animations play at whatever speed the user scrolls. Fast scrolling compresses the animation. Slow scrolling stretches it. Make sure your animation looks good at both extremes.

4. Missing the both Keyword

Without animation-fill-mode: both (or both in the shorthand), the element snaps back to its pre-animation state when the animation isn’t active. For reveal effects, you almost always want both so the element stays visible after scrolling past it.

Best Practices

  • Use view() for element-level reveals and scroll() for page-level progress effects.
  • Animate only transform and opacity to stay on the compositor thread.
  • Set tight animation-range values. entry 0% entry 80% feels much snappier than the full range.
  • Add @supports guards for any animation where the pre-animation state would be visually broken (e.g., opacity: 0).
  • Test on mobile. Scroll behavior differs significantly on touch devices. Check that your animations feel right with finger scrolling, not just mouse wheels.
  • Don’t overdo it. One or two scroll-driven effects per page add polish. Ten of them create a theme park. Restraint is good design.

Wrapping Up

CSS scroll-driven animations deliver what developers have wanted for a decade: scroll-linked visual effects with zero JavaScript, zero jank, and zero dependencies. The API is surprisingly small — animation-timeline, scroll(), view(), and animation-range cover 90% of use cases.

Use them for progress bars, reveal effects, parallax, and header transitions. Skip JavaScript scroll listeners for anything purely visual. The browser does it better.

FAQ

Do scroll-driven animations work with CSS transitions?

No. Scroll-driven animations only work with @keyframes animations. CSS transitions are time-based by nature and can’t be linked to scroll progress. Use the animation property, not transition.

Can I control the direction of the animation?

Yes. animation-direction: reverse works as expected. You can also use alternate for animations that should play forward on scroll-down and backward on scroll-up.

What about Intersection Observer?

Intersection Observer triggers at specific visibility thresholds and requires JavaScript. It’s still useful for lazy loading images or tracking analytics events. For purely visual effects (fade-in, slide-up), scroll-driven animations are simpler and more performant.

Can I pause scroll animations at a specific point?

Not directly with CSS. But animation-range effectively controls this — once the scroll position exceeds your range, the animation stays at its end state (with fill-mode: both). For more complex control, you can still combine CSS animations with JavaScript via the Web Animations API.

editor's pick

latest video

news via inbox

Nulla turp dis cursus. Integer liberos  euismod pretium faucibua

Leave A Comment