Star5

CSS Scroll-Driven Animations in Angular: Build Scroll Effects Without JavaScript

Learn how to use CSS scroll-driven animations in Angular with animation-timeline, scroll(), and view() — zero JavaScript scroll listeners needed.

RJ
RAMA JONNADA
Senior Frontend Developer with 4+ years of experience in Angular and modern web technologies.

TL;DR: CSS Scroll-Driven Animations let you link any CSS animation to scroll position using animation-timeline: scroll() or animation-timeline: view() — no fromEvent, no IntersectionObserver, no requestAnimationFrame. This post shows you how to use them inside Angular components, when to pair them with Angular directives, and where ZyraUI components plug right in.

Scroll-triggered animations have always required JavaScript. The old pattern — listen to scroll events, calculate offsets, toggle classes or update inline styles — has been standard for years, but it comes with real costs: jank from main-thread blocking, memory leaks from forgotten listeners, and bundle weight from animation libraries. CSS Scroll-Driven Animations change that completely.

The spec shipped as Baseline widely available in 2025 and is fully supported in Chrome, Edge, Firefox 115+, and Safari 18+. As of mid-2026, it is safe to use in production for the vast majority of Angular applications. This post walks through the core APIs, real use cases you can build today, and how to integrate them cleanly into an Angular component library workflow.


The Problem: Scroll Animations Are Expensive to Build Well

Wiring a scroll-triggered header fade or a progress bar to scroll position in Angular has historically meant something like this:

// The old way — main-thread scroll listener, manual cleanup
import { Component, HostListener, signal, OnDestroy } from '@angular/core';

@Component({
  standalone: true,
  template: `<div [style.opacity]="opacity()">content</div>`,
})
export class FadeOnScrollComponent implements OnDestroy {
  opacity = signal(1);
  private handler = () => {
    const scrolled = window.scrollY;
    this.opacity.set(Math.max(0, 1 - scrolled / 300));
  };

  constructor() {
    window.addEventListener('scroll', this.handler, { passive: true });
  }

  ngOnDestroy() {
    window.removeEventListener('scroll', this.handler);
  }
}

This runs on the main thread. Every scroll tick calls JavaScript, computes a value, and forces Angular to update the DOM. Even with passive: true and OnPush, scroll handlers add measurable overhead on mid-range mobile devices.

CSS Scroll-Driven Animations move this entirely into the browser compositor thread — the same thread that handles GPU-accelerated transform and opacity animations. The browser does the work; your Angular component does nothing.


The New Concept: animation-timeline, scroll(), and view()

CSS Scroll-Driven Animations introduce two new timeline types you can assign to any CSS animation via animation-timeline:

  • scroll() — ties the animation progress to how far the user has scrolled inside a scroll container (defaults to the nearest scrollable ancestor, or you can target the root).
  • view() — ties the animation progress to how much of the element is visible inside the viewport (or a named scroll container).

Both replace the traditional animation-duration-based timeline. When you use a scroll timeline, time is replaced by scroll position. The animation is at 0% when the scroll position is at the start, and 100% when it reaches the end.

/* Reading progress bar — tied to the page scroll */
@keyframes grow-width {
  from { transform: scaleX(0); }
  to   { transform: scaleX(1); }
}

.progress-bar {
  transform-origin: left;
  animation: grow-width linear;
  animation-timeline: scroll(root);   /* root = document scroll */
  animation-fill-mode: both;
}
/* Fade-in as element enters viewport */
@keyframes fade-up {
  from {
    opacity: 0;
    translate: 0 2rem;
  }
  to {
    opacity: 1;
    translate: 0 0;
  }
}

.card {
  animation: fade-up ease-out both;
  animation-timeline: view();          /* tied to this element's viewport entry */
  animation-range: entry 0% entry 40%; /* only animate during the first 40% of entry */
}

The animation-range property is what makes view() timelines precise. Without it the animation spans the full scroll range. The named ranges — entry, exit, contain, cover — map to how much of the element is inside the scroll container.

See the full spec on MDN: Scroll-driven animations and the comprehensive web.dev guide to scroll-driven animations.


Using Scroll-Driven Animations in Angular

Because CSS Scroll-Driven Animations are pure CSS, they integrate into Angular components without any special Angular API — just add the CSS to your component stylesheet.

Reading progress bar component

// reading-progress.component.ts
import { Component, ChangeDetectionStrategy } from '@angular/core';

@Component({
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `<div class="progress-bar" aria-hidden="true"></div>`,
  styles: [`
    .progress-bar {
      position: fixed;
      top: 0;
      left: 0;
      width: 100%;
      height: 4px;
      background: var(--color-primary, #6366f1);
      transform-origin: left;
      animation: reading-progress linear both;
      animation-timeline: scroll(root);
    }

    @keyframes reading-progress {
      from { transform: scaleX(0); }
      to   { transform: scaleX(1); }
    }
  `],
})
export class ReadingProgressComponent {}

No HostListener. No DestroyRef. No signal. The browser handles everything — composited on the GPU — and the component stays perfectly OnPush clean.

Reveal-on-scroll directive

For cards and sections that should animate into view, an Angular attribute directive lets you apply the CSS class declaratively:

// scroll-reveal.directive.ts
import { Directive, ElementRef, inject, OnInit, input } from '@angular/core';

@Directive({
  standalone: true,
  selector: '[scrollReveal]',
})
export class ScrollRevealDirective implements OnInit {
  delay = input<string>('0ms');
  private el = inject(ElementRef<HTMLElement>);

  ngOnInit() {
    const host = this.el.nativeElement as HTMLElement;
    host.style.setProperty('--reveal-delay', this.delay());
    host.classList.add('scroll-reveal');
  }
}
/* global styles or a utility stylesheet */
@keyframes reveal-up {
  from {
    opacity: 0;
    translate: 0 1.5rem;
  }
  to {
    opacity: 1;
    translate: 0 0;
  }
}

.scroll-reveal {
  animation: reveal-up ease-out both;
  animation-duration: 0.4s;             /* fallback for non-scroll-timeline browsers */
  animation-timeline: view();
  animation-range: entry 0% entry 35%;
  animation-delay: var(--reveal-delay, 0ms);
}

@media (prefers-reduced-motion: reduce) {
  .scroll-reveal {
    animation: none;
  }
}

Usage in a component template:

import { Component, ChangeDetectionStrategy } from '@angular/core';
import { ScrollRevealDirective } from './scroll-reveal.directive';

@Component({
  standalone: true,
  imports: [ScrollRevealDirective],
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <section>
      <h2 scrollReveal>Why it matters</h2>
      <p scrollReveal delay="100ms">First point...</p>
      <p scrollReveal delay="200ms">Second point...</p>
    </section>
  `,
})
export class LandingPageComponent {}

Always include a prefers-reduced-motion rule. Users who have set "reduce motion" in their OS settings expect animation-free interfaces, and ignoring this preference is an accessibility failure.


Using ZyraUI for Scroll-Driven Animation Demos

ZyraUI's card and layout components are ideal for demonstrating scroll-driven reveals because they already handle spacing, shadows, and theme-aware colors through the token system. You can apply the ScrollRevealDirective directly to zyra-card without modifying the component:

import { Component, ChangeDetectionStrategy } from '@angular/core';
import { ZyraCardComponent } from '@zyra-ui/angular';
import { ScrollRevealDirective } from './scroll-reveal.directive';

@Component({
  standalone: true,
  imports: [ZyraCardComponent, ScrollRevealDirective],
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    @for (feature of features; track feature.id; let i = $index) {
      <zyra-card
        scrollReveal
        [delay]="(i * 80) + 'ms'"
        [title]="feature.title"
        [description]="feature.description"
      />
    }
  `,
})
export class FeaturesGridComponent {
  features = [
    { id: 1, title: 'Fast', description: 'Zero JS scroll overhead.' },
    { id: 2, title: 'Accessible', description: 'Respects prefers-reduced-motion.' },
    { id: 3, title: 'Theme-aware', description: 'Reads token-based colors.' },
  ];
}

The staggered delay produces a cascade effect as the grid scrolls into view — entirely CSS-driven after the directive sets the custom property once. Explore all 60+ free components at zyraui.dev, including the full component library and theming system.


A Note on Browser Support and Progressive Enhancement

The @supports rule works for CSS Scroll-Driven Animations, letting you opt in only where the API is available:

@supports (animation-timeline: scroll()) {
  .progress-bar {
    animation-timeline: scroll(root);
  }
}

For browsers without support (primarily older Safari and Firefox before 115), the element simply has no animation — which is an acceptable and safe fallback for decorative effects. For functional animations (e.g. a progress indicator that users depend on), pair it with a lightweight signal-based fallback using @if blocks in Angular templates.

Check current browser compatibility on the MDN compatibility table for animation-timeline.


Wrapping Up

CSS Scroll-Driven Animations eliminate the most common source of scroll-related JavaScript in Angular apps. Reading progress bars, reveal-on-entry effects, parallax-lite transforms, and sticky header transitions can all be handled at the CSS layer — compositor-threaded, accessible with one media query, and completely free of Angular lifecycle code. Start with animation-timeline: scroll() for page-level effects and animation-timeline: view() for element entry animations, and pair them with ZyraUI components through simple directive composition. Browse the full ZyraUI docs to see how the component library fits into this pattern.


Frequently asked questions

What browsers support CSS scroll-driven animations in 2026?

Chrome 115+, Edge 115+, Firefox 115+, and Safari 18+ all support animation-timeline: scroll() and animation-timeline: view(). Combined browser market share for supported versions is above 90% globally as of mid-2026, making these APIs safe for production use with a no-animation fallback via @supports or prefers-reduced-motion.

Do CSS scroll-driven animations work with Angular's ViewEncapsulation?

Yes. Because the animations are defined in CSS (either in the component's styles array or a global stylesheet), they follow the same encapsulation rules as any other CSS. Styles in the styles array are scoped to the component; styles you want to apply globally (like a .scroll-reveal utility class) belong in styles.scss or an imported global stylesheet.

Should I use CSS scroll-driven animations or the Angular Animations API?

Use CSS scroll-driven animations for decorative, scroll-position-linked effects (reveals, progress bars, parallax). Use the Angular Animations API (@angular/animations) for state-driven transitions triggered by component logic — enter/leave animations, conditional state changes, route transitions. They solve different problems and work well together in the same application.

How do I handle prefers-reduced-motion with scroll-driven animations?

Wrap any scroll-driven animation in a @media (prefers-reduced-motion: no-preference) block, or add a @media (prefers-reduced-motion: reduce) { animation: none; } override. The latter is safer because it ensures the default state (no animation) is available to all users and the animation is an explicit opt-in rather than opt-out.

Topics
CSSAngularanimationsfrontendperformance