We can see a clear trend when we look at the current development of APIs on the web and their adoption in the browser landscape. The gap between native and web applications is getting smaller and smaller. Features such as the File System or Push API increasingly blur the boundaries and differentiation between web and native desktop or mobile applications. However, when it comes to mobile or cross-platform development, there is one specific area where web apps still have a disadvantage over natively developed apps: animations, or more precisely, container transforms, like the ones illustrated in the Material Design guidelines.
Native developers always had the advantage of dedicated animation APIs to accomplish this type of transform animations. But I don’t want to claim that these animations have never been possible on the web. It’s just the structure of the DOM and how navigations work on the web that made it very cumbersome—until now.
So, let’s have a look at how view transitions work before we dive in.
How view transitions work
The cool thing about view transitions is that you mainly control them via CSS and only need a little sprinkle of JavaScript. To trigger the transition, you call the native startViewTransition()
function exposed by the document
.
document.startViewTransition(async () => {
// Change something in the DOM ...
});
When the function is called, the browser simply takes a screenshot of the current page. The function also accepts a callback function that notifies the browser when the desired DOM change has been executed. A further screenshot is taken, and the browser then cross-fades both images as a default. Et voila, you have successfully executed a view transition. But how is the crossfade accomplished, and how can I manipulate the animation using CSS? Therefore, there should be any accessible DOM elements, right? Yes, there are some DOM elements. But only only for the duration of the transition.
::view-transition
└─ ::view-transition-group(root)
└─ ::view-transition-image-pair(root)
├─ ::view-transition-old(root)
└─ ::view-transition-new(root)
After the screenshots are taken, the browser creates a pseudo DOM structure ::view-transition
that sits on top of every other DOM element so it does not interfere with them or get accidentally overlaid. The ::view-transition
represents the overlay root, the ::view-transition-group
represents the root of a view transition, and the ::view-transition-image-pair
represents the container of the old and the new screenshots. Each pseudo element can be separately styled using CSS. You can also “cut out” sub-parts of each screenshot to animate them separately by setting a view-transition-name
via CSS on an element (note: each view transition name has to be unique). For further reading on the View Transitions API, look at the official MDN docs.
How to use/activate view transitions in the Angular Router
Next, I want to show you how to activate view transitions in Angular. I prepared a little app that will serve as a demonstration object. Nothing too fancy to keep everything nice and tidy. Just some image cards that navigate to a detail view when you click on them. Also, feel free to check out the repository and experiment with it. Currently, we use the default configuration, and the navigation between the card and detail view happens instantly.
So, let’s go to the app.config.ts file and add the following code snippet in the provider’s array.
export const appConfig: ApplicationConfig = {
providers: [provideRouter(routes, withViewTransitions())],
};
We have successfully activated the view transition feature for the Angular router. Try it out, navigate between the card and detail views, and you will recognize the difference.
The navigation has changed its behavior from instant switching to a subtle cross-fade. If the animation is too quick for you, just open the developer tools of your browser, go to the animations panel and slow it down. This is also the place to go if you want to debug any kind of animation. The cross-fade is already quite remarkable, but I think we can make the transition stand out even more.
Let's make this container transition pop
To achieve a first and straightforward container transition of the card image, we need it to be cut out of the initial screenshot and animated separately. We accomplish this by setting a view-transition-name
CSS property on the element that gets clicked and on the corresponding image container in the detail view. Also some additional logic is needed to change the view-transition-name
on the clicked item dynamically. But the result should look a bit like this. Let’s try it out.
Amazing, isn’t it? Just by “cutting out,” the browser adds another default transition animation to it, and you get this excellent effect. As mentioned before, you have full control over the animations. If you don’t like the default, just override it with your own. But as a first try, it looks already very neat.
If you look closely, you will probably notice that we have an issue. The transition animation only plays when we navigate “forward.” So, how do we also animate when we go back?
But how do we go back?
We also need to set the view transition names for the corresponding card elements to go back. More precisely, we need to know where we originally came from. There are different valid approaches to solving this problem. You could probably use some kind of state management, e.g., the new SignalStore, to store the element ID where we initially came from and apply the view-transition-name
later on when we go back. Or, if you don’t like the idea of pulling another dependency in, we could go with the following approach.
The withViewTransitions()
function we provided in the app.config.ts also accepts a callback function onViewTransitionCreated()
, which gives us additional information about the animation. Among other things, from which URL we come and to which we go. We can use this information to set the view transition names. To access this information application-wide, we leverage a service to provide it. The result looks like this.
// app.config.ts
export const appConfig: ApplicationConfig = {
providers: [
provideRouter(
routes,
withComponentInputBinding(),
withViewTransitions({ onViewTransitionCreated }),
),
],
};
function onViewTransitionCreated(info: ViewTransitionInfo): void {
const viewTransitionService = inject(ViewTransitionService);
viewTransitionService.currentTransition.set(info);
info.transition.finished.finally(() => {
viewTransitionService.currentTransition.set(null);
});
}
//view-transition.service.ts
@Injectable({
providedIn: 'root',
})
export class ViewTransitionService {
currentTransition = signal(null);
}
Now that we have the information available, let’s find a reusable way to utilise it in our application. To do so, we create a new ViewTransitionDirective
that encapsulates the behavior of applying a view-transition-name
when needed.
@Directive({
selector: '[appViewTransition]',
standalone: true,
host: { '[style.view-transition-name]': 'viewTransitionName()' },
})
export class ViewTransitionDirective {
private readonly viewTranistionService = inject(ViewTransitionService);
protected readonly name = input('', { alias: 'appViewTransition' });
protected readonly id = input();
protected readonly viewTransitionName = computed(() => {
const currentTransition = this.viewTranistionService.currentTransition();
const apply =
Number(currentTransition?.to.firstChild?.params['id']) === this.id() ||
Number(currentTransition?.from.firstChild?.params['id']) === this.id();
return apply ? this.name() : 'none';
});
}
Each card has a unique ID used in the route parameters to access the corresponding member data. We simply apply the view-transition-name
only when we navigate to or come from a URL that contains the same ID as our card element. Since we rely entirely on signals, we can encapsulate the logic in a computed and thus automatically ensure that the function is always re-evaluated as soon as one of the signals used inside of computed()
changes. Usually, we manage the style binding with the help of the @HostBinding()
decorator. As signals are not designed for combined use with decorators, we use the second option we have by leveraging the host
property of the @Directive()
decorator.
Now just apply the directive and its inputs in our HomeComponent
and et voila! Forward and backward animations play seamlessly.
Conclusion
The examples showed how easy it is to activate view transitions in your Angular application and use them to lift your route animation game to the next level. Of course, there are still technical limitations regarding browser support. But even these will hopefully quickly disappear into thin air.
In the second part of the series, we will examine how to use view transitions on a single page (e.g., for sorting animations) and how to use Angular features to make them as reusable as possible. Stay tuned!
GitHub Repository
You can find a sample implementation with all branches of the above examples right in this GitHub repository (all examples with Angular 17.3.0):
https://github.com/thinktecture/article-view-transition-api-angular