View Transitions with Angular (SPA)

View Transitions with Angular (SPA)

View transitions is a cool and powerful API we can use in our Angular application to create animated transitions between views to elevating the overall user experience and enhancing usability, resulting in positive user feedback.

Update

[2023-09-12] Angular router now supports View Transitions. (#73e4bf2)

Use Case

The use case in this article is a single page application built with Angular to demonstrate a portfolio of a user. As in most customer projects, I also set up and use a design system here. I took the 3rd version of the great Google’s Material Design.

The routing is configured so that I have 3 routes:

I created 8 view transitions during (router) navigation to animate the state change with CSS.

Live Demo

The deployed demo application showed in the video above currently only works with the latest Chromium browser, because it is a new API. I recommend the Canary version.

Please check the browser support for the ViewTransition API (Can I Use?).

Source Code

The source for the demo application is available on GitHub: Source Code

⚠️ To run the application on your machine you have to clone, build and link the Angular repo

Motion Design

Motion Design is an important part of design systems that enhances the user experience of our web applications. When a user clicks on an interactive control, it triggers navigation from one view to another. The visualization of this transition as an animation gives the user enough time to comprehend the change in the user interface, ultimately improving the overall user experience. Without this animation, users may need more time to understand the switch between views, which can lead to poorer acceptance and thus a poorer user experience.

Learn more about motion design at Material’s design system.

CSS transitions, CSS animations, Web Animation API, Angular animations and route transition animations provides great APIs to implement different navigation transitions. In some cases this works perfect, but in some cases this results in complex animation definitions and complex DOM structure. For these cases we can use the new View Transition API ✨ to simplify the animations.

How View Transition API works

Idea 💡

The focus in this article is on the animations of views while navigating between pages (routes). There are other types of animation that I may describe in another article. So let’s take a closer look at this idea before we get to the code.

The video above shows the basic idea of a transition from overview page (list route) to the detail page (detail route). We can observe two transitions:

Now it should be clear that we have the before state (old state) and the after state (new state) for two elements, the preview and the root (everything except preview).

API

Let’s take a look at the API and use pseudo code to simplify the case.

Pseudo code to demonstrate an old state (::view-transition-old) on the list route:

<html> <!-- root -->
    <body>
        <list-page>
            <list>
                <preview></preview>
                <preview class="active"></preview> <!-- preview -->
                <preview></preview>
                <preview></preview>
            </list>
        </list-page>
    </body>
</html>

Pseudo code to demonstrate the new state (::view-transition-new) on the detail route:

<html> <!-- root -->
    <body>
        <detail-page>
            <preview></preview> <!-- preview -->
        </detail-page>
    </body>
</html>

The browser needs information about the element we like to use for our transition. We can use CSS to assign a view transition name to an element. The name is the identifier and must be unique.

Definition of the view transition in CSS:

preview.active {
    view-transition-name: 'preview'
}

root is the view transition name for the root element (whole page). A definition is not necessary, it’s a part of user agent stylesheet. The view-transition-name must be unique on a page otherwise the animation is ignored.

Let’s assume we’re on the list route, we can run this code to run the transition:

1
2
3
4
5
document.startViewTransition(() => {
    // Navigation to details route,
    // generally speaking, DOM manipulation
    router.navigateByUrl('/detail/42'); 
});

Lines description:

Without further CSS definition the browser uses the cross-fade as a default animation.

In these steps, a pseudo element tree structure like this is created:

::view-transition
├─ ::view-transition-group(root)
│ └─ ::view-transition-image-pair(root)
│     ├─ ::view-transition-old(root)
│     └─ ::view-transition-new(root)
└─ ::view-transition-group(preview)
  └─ ::view-transition-image-pair(preview)
      ├─ ::view-transition-old(preview)
      └─ ::view-transition-new(preview)

Now we can use CSS to make interesting transitions and apply the style to pseudo elements.

Angular & View Transitions

Angular will support view transitions in the next release. It’s already merged.

Just add withViewTransitions() to router features array to enable view transitions. ✨

// app.config.ts
import {provideRouter, withViewTransitions } from '@angular/router';

export const appConfig: ApplicationConfig = {
    providers: [provideRouter(routes, withViewTransitions())],
};

The View Transition API is new and there no support in Angular, but there is an issue #49401 to address that. I forked the Angular repo and changed the router to demonstrate the view transitions in my demo application. 💡 I think the router is responsible for the invocation of the startViewTransition method during router navigation.

Workaround

You can try the View Transition API without changing Angular framework.

// list.component.ts

private readonly document = inject<Document & {startViewTransition: (callback: () => void) => {}}>(DOCUMENT);

navigate() {
    this.ngZone.runOutsideAngular(() => {
        if(!this.document.startViewTransition){
            this.ngZone.run(() => {
                void this.router.navigate(['detail', '42']);
            });
        }
    });
}

Animation Implementation

Usually we define the animations in the components style to animate the HTML element inside the components. In case of view transition, we want to apply animation style to the new pseudo element tree. For this reason I have defined the style globally and stored it in different style files.

src/styles/view-transitions
├─ app.component.transitions.scss
├─ app-bar.component.transitions.scss
├─ detail.component.transitions.scss
├─ editor.component.transitions.scss
├─ project-card.component.transitions.scss
├─ project-card.component.transitions.scss
└─ view-transitions.scss // index file

Expand / Collapse Animation

The video above shows an expand animation. The animation moves the preview part of the project card from small list item to large preview on the details page.

::view-transition-old (list route)

The list route contains a list of project cards. The current implementation of the view transitions supports only one element with same name.

The highlander principle There can be only one!.

Therefore we have to assign the view-transition-name to the project card only when we click on the card. 💡 I place this code in the component to simplify the demo, but this logic should be part of the router.

// project-card.component.ts

async nav(segments: string[]): Promise<void> {
    // add class to assign view-transition-name
    this.elementRef.nativeElement.classList.add('active');
    // startViewTransition, navigate to detail route and animate the state change
    await this.router.navigate(segments);
}
// project-card.component.scss

&.active {
    labs-project-preview {
        view-transition-name: preview;
    }
}
::view-transition-new (detail route)

The target page component requires the same view-transition-name:

// detail.component.scss

:host {
    labs-project-preview {
        view-transition-name: preview;
    }
}
Animation Style

The previous code enables the default cross-fade animation on both elements (root, preview). In the demo, however, you can see that the video is playing while it is transforming from an old state to the new state. To reach this behavior some style and code is necessary. The following stylesheet is a global style file that is applied to the pseudo elements.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// project-card.component.transitions.scss

::view-transition-old(preview) {
    display: none; 
}

::view-transition-new(preview) {
    animation: none;
    z-index: var(--index-preview);
}
Workaround

The expand view transition should now look cool, but wait there is an issue. There is an interruption while playing the video. The reason is that the video element in the preview component is not the same HTML element. The list component contains a video element and the detail component also contains its own.

There is a workaround, I created, but 💡 I think this should be part of the router too. The workaround should demonstrate the idea.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// project-preview.component.ts

export const videoFactory = (id: string) => `
<video muted autoplay loop src="assets/${id}.mp4"></video>
`;

@Component({
    selector: 'labs-project-preview',
    standalone: true,
    imports: [CommonModule],
    templateUrl: './project-preview.component.html',
    styleUrls: ['./project-preview.component.scss'],
})
export class ProjectPreviewComponent implements OnChanges {
    @Input({required: true}) id = '';
    private readonly cache = inject(ViewTransitionCacheService);
    private readonly elementRef = inject(ElementRef);
    
    async ngOnChanges(changes: SimpleChanges): Promise<void> {
        const {nativeElement} = this.elementRef;
        if (this.cache.has(this.id)) {
            // Use video instance from cache 
            const video = this.cache.get(this.id) as HTMLVideoElement;
            await video.play();
            nativeElement.append(video);
        } else {
            // Create new video instance
            nativeElement.innerHTML = videoFactory(this.id);
            const element = nativeElement.children[0] as HTMLElement;
            this.elementRef.nativeElement.appendChild(element);
            this.cache.set(this.id, element);
        }
    }
}

Micro Animations

Take a look at the video again, there are some micro animations such as back-button with a slide in animation. The animation style sheet looks like this:

// detail.component.transitions.scss

::view-transition-new(back-button):only-child {
    animation: slide-in-from-left var(--view-transition-duration) ease;
}

@keyframes slide-in-from-left {
    from {
        transform: translateX(-200%);
    }
    to {
        transform: translateX(0);
    }
}

The slide-in-from-left will be applied if the pseudo element ::view-transition-new(back-button) is the only child in the image pair element ::view-transition-image-pair(back-button). It’s the parent element. :only-child is a pseudo-class that helps us to avoid the slide in animation between detail and editor route. Both routes includes the back-button.

Close Animation

I created the close animation to animate the transition from detail to list route.

// detail.component.transitions.scss

::view-transition-old(detail-page) {
    animation: close-to-top-left-corner var(--view-transition-duration) ease;
}

@keyframes close-to-top-left-corner {
    from {
        clip-path: ellipse(100vmax 100vmax at center);
    }
    
    to {
        clip-path: ellipse(.1rem .1rem at 2rem 4.5rem);
    }
}

The animation can be easily implemented with the clip-path feature. The definition of view-transition-name: detail-page should be activated when you click the back button.

// detail.component.ts 

navigateBack() {
    this.elementRef.nativeElement.classList.add('full-page-transition')
    void this.router.navigate([...]);
}

Special Close Animation 🚀

So far, a few simple standard animations had been implemented, time for something special! The CodePen below shows a full page transition. The USS Enterprise (NCC 1701) moves with warp 9 between views. ✨ This animation could be an alternative to the close animation.

And this is how the animation looks in the Angular application:

// app-bar.component.transitions.scss

::view-transition-old(special) {
    mask: url("~assets/tail.svg") center top no-repeat,
    url("~assets/space-landscape.svg") center center no-repeat,
    url("~assets/ncc1701.svg") center center no-repeat;
    mask-repeat: no-repeat;
    mask-size: 10rem 30rem, 500% 500%, 100% 30%;
    animation: hide-view-landscape 2.8s cubic-bezier(0.975, -0.005, 0, 1.02) forwards;
}

The basic idea is to use the old element (screenshot) and mask it with 3 images. Mask-position and transform properties are changed during CSS animation:

// app-bar.component.transitions.scss

@keyframes hide-view-landscape {
  from {
    mask-position: 50% 120%, 50% 0%, 50% 150%;
    transform: scale(1);
  }

  45% {
    transform: scale(1) translateY(-5%);
  }
  50% {
    mask-position: 50% 120%, 50% 72%, 50% 60%;
  }

  80% {
    mask-position: 50% -80%, 50% 140%, 50% -70%;
  }

  to {
    mask-position: 50% -80%, 50% 140%, 50% -70%;
    transform: scale(1) translateY(-10%);
  }
}

Don’t forget the view transition name:

// detail.component.scss

:host {
    .special & {
        view-transition-name: special;
    }  
}

Reduce-Motion

I recommend to handle reduce motion when implementing animations.

Debugging

Debugging is easy because we can use the browser’s dev tools to debug animations and the pseudo-element tree.

Findings / Issues

Infinite Loop

You should avoid infinite animations 🤣.

::view-transition-new(root) {
  animation: infinite-loop 1s linear infinite; // Don't use infinite 😱
}

@keyframes infinite-loop {
  from {
    filter: blur(20rem);
  }

  to {
    filter: blur(0);
  }
}

Pseudo-Element tree is preserved after multiple reloads

[CLOSED] https://bugs.chromium.org/p/chromium/issues/detail?id=1434757

I created a temporary workaround to fix this issue during initial load / bootstrap of the Angular application:

setTimeout(() => {
    bootstrapApplication(AppComponent, appConfig)
        .catch((err) => console.error(err));
}, 10);

View Transitions with iFrames

In my case I use recorded videos to preview the projects. An alternative used by cool dev sites is the iframe. It allows you to preview a demo page with real code. Above I showed how to cache and reuse videos so that playback is not interrupted. This would not work with iframes, since they are re-executed when moved in the DOM.

Conclusion

To sum up, the View Transition API is a game-changer for developers looking to enhance their user interface with smooth and seamless animations. With the ability to easily animate between views, developers can create a more engaging user experience without the need for complex DOM structures or Angular animations. While the current implementation involves placing code in Angular application or patching the router, the future holds even greater promise as the router takes over this functionality.

If you’re interested in learning more about View Transitions, Angular, Material, and CSS, be sure to follow me on Twitter for more insights and updates.