🪄 Introduction: A Glimpse of Magic
When I first saw a presentation about the View Transition API, it blew my mind. It is truly magical!
The core idea is that the browser captures a visual snapshot of DOM elements marked with the view-transition-name
CSS property before a DOM change. After the DOM change, the differences are animated using CSS Animations.
To truly appreciate its capabilities, take a look at these examples:
- Example 1: View Transitions like IsotopeJS
- Example 2: Adding and removing cards
- Example 3: Playlist app
Here's a classic code example illustrating how to trigger a view transition:
function handleClick(e) {
// Fallback for browsers that don't support this API:
if (!document.startViewTransition) {
updateTheDOMSomehow();
return;
}
// With a View Transition:
document.startViewTransition(() => updateTheDOMSomehow());
}
updateTheDOMSomehow
? Somehow?? And it will just work? Yep, it will. This highlights the remarkable flexibility of the API.
I won't delve into the fundamentals of the View Transition API in this article. The official documentation provides excellent information if you're not already familiar with it. A basic understanding of Angular components, signals, and CSS is recommended.
🚧 The Angular Challenge: Beyond Route Transitions
Working with the View Transition API in Angular is already possible for navigating between routes. However, animating individual elements on a page is not supported out of the box. How can this gap be bridged?
⚙️ Setting the Stage
Begin by creating a basic Angular component to experiment with:
@Component({
selector: 'basic-demo',
styleUrl: './basic-demo.component.scss',
template: `
<button (click)="toggle()">Toggle position</button>
<div class="relative">
<div class="box" [class]="position()">
<span>{{ position() }}</span>
</div>
</div>
`,
})
export class ViewTransitionBasicDemoComponent {
position = signal<'left' | 'right'>('left');
toggle() {
this.position.set(this.position() === 'left' ? 'right' : 'left');
}
}
This component displays a box initially positioned on the left. Clicking the button toggles its visual position between the left and the right, while also displaying the current position within the box. The CSS has been omitted for brevity. Find the complete example on StackBlitz.
Now, attempt to animate the element using the View Transition API.
First, add the following to the application's global styles:
:root {
view-transition-name: none;
}
This ensures that only elements explicitly marked with view-transition-name
will be animated.
According to the View Transition API, two key elements are needed for the animation to work:
- The element intended for animation must have the
view-transition-name
CSS property set with a unique value. - The
document.startViewTransition()
function needs to be called. The provided callback function should update the DOM.
Applying the CSS property is straightforward:
<div class="box" [class]="position()" style="view-transition-name: box">
<span>{{ position() }}</span>
</div>
However, the second part presents a challenge in Angular.
A naive attempt might look like this:
toggle() {
document.startViewTransition(() => {
this.position.set(this.position() === 'left' ? 'right' : 'left');
});
}
... and surprisingly, it works! The animation occurs. See it in action on StackBlitz.
You will see why this is rather surprising as we introduce a little bit more complexity to our example and talk more about SPA paradigm on rendering.
Let's add a computed
property and another DOM element that would be shown depending on the new computed
property:
@Component({
selector: 'app-root',
styles: `// omitted for brevity`,
template: `
<button (click)="toggle()">Toggle position</button>
<div class="box-container">
<div class="box" [class]="position()" style="view-transition-name: box">
<span>{{ position() }}</span>
</div>
</div>
@if (isCircleVisible()) {
<div class="circle" style="view-transition-name: circle"></div>
}
`,
})
export class App {
position = signal<'left' | 'right'>('left');
isCircleVisible = computed(() => this.position() === 'right');
toggle() {
document.startViewTransition(() => {
this.position.set(this.position() === 'left' ? 'right' : 'left');
});
}
}
Now, while toggling between "left" and "right", the box still animates, but the circle is not animated despite having view-transition-name
CSS property as seen in this StackBlitz example.
🔄 The SPA Paradigm: Data Updates vs. DOM Manipulation
Recall that the startViewTransition
function's callback expects a function that updates the DOM:
document.startViewTransition(() => {
updateTheDOMSomehow();
});
However, in Single Page Applications (SPAs) like Angular, developers typically work with data (state) and rely on the framework to render the DOM in response to data changes. Direct DOM manipulation is generally avoided.
So, the updateTheDOMSomehow()
part isn't directly available to a developer working with Angular. Instead, the developer would write the updateTheDataSomehow()
function, which doesn't directly align with the View Transition API's requirement.
The animation works for the box in the simpler case likely because Angular is fast enough to synchronize the state change and the DOM update within the execution of the callback function:
document.startViewTransition(() => {
this.position.set(this.position() === 'left' ? 'right' : 'left');
// Angular is quick enough to update DOM dependent on `position` here
});
However, the computed
property likely schedules a separate render tick that occurs outside the startViewTransition
callback. This explains why the circle isn't animated.
Furthermore, the box animation might also become inconsistent. The animation might work when switching from left to right but fail from right to left. Removing the circle-related code might resolve this inconsistency.
The key takeaway is that Angular renders DOM changes at its own pace, influenced by various factors. The data can't simply be updated within the startViewTransition
callback with the expectation that the DOM would be ready immediately.
There needs to be a way to ensure the DOM has been updated within that callback:
document.startViewTransition(async () => {
this.position.set(this.position() === 'left' ? 'right' : 'left');
await angularUpdatedTheDOM(); // <-- Need to do this
});
🌉 Bridging the Gap between the View Transition API and Angular
To adhere to the Single Responsibility Principle, encapsulate the view-transition-related logic within a dedicated service:
@Injectable({ providedIn: 'root' })
export class ViewTransitionService {
run(stateChangeFn: () => void) {
if (!document.startViewTransition) {
stateChangeFn();
return;
}
document.startViewTransition(async () => {
stateChangeFn();
await createRenderPromise(); // <-- TODO
});
}
}
Angular provides the afterNextRender
function, which is precisely what is needed to create the createRenderPromise
function:
function createRenderPromise(injector: Injector) {
return new Promise<void>((resolve) => {
afterNextRender({
read: () => {
resolve();
}
}, { injector });
});
}
Now, update the component to utilize this new service:
export class App {
private viewTransitionService = inject(ViewTransitionService);
position = signal<'left' | 'right'>('left');
isCircleVisible = computed(() => this.position() === 'right');
toggle() {
this.viewTransitionService.run(() => {
this.position.set(this.position() === 'left' ? 'right' : 'left');
});
}
}
With this service in place, all animations should now work as expected. See it running on StackBlitz.
🚦 Handling Concurrent Transitions: A Potential Pitfall
Let's refine our demo slightly. First, slow down all view transition animations to 3 seconds to observe the behavior more clearly:
::view-transition-group(*) {
animation-duration: 3s;
}
Now, add another element - a triangle - that will be animated somewhat independently:
@if (isTriangleVisible()) {
<div class="triangle" style="view-transition-name: triangle"></div>
}
For simplicity, update the component to show the triangle after a short delay:
isTriangleVisible = signal<boolean>(false);
toggle() {
this.viewTransitionService.run(() => {
this.position.set(this.position() === 'left' ? 'right' : 'left');
});
setTimeout(() => {
this.viewTransitionService.run(() => {
this.isTriangleVisible.set(!this.isTriangleVisible());
});
}, 700);
}
See this implementation on StackBlitz.
Also add some logging to the run
method of the ViewTransitionService
:
run(stateChangeFn: () => void) {
// ...
console.log('start transition');
document.startViewTransition(async () => {
console.log('state change');
stateChangeFn();
await createRenderPromise(this.injector);
console.log('rendered');
});
}
Upon pressing the "toggle" button, an unexpected behavior is observed. The first two elements begin animating, but after 700ms, the animation abruptly stops. The animated elements jump to their final state and then the triangle element starts its animation.
The console output shows:
start transition
state change
rendered
start transition
state change
rendered
This behavior is actually by design. Currently, only one view-transition animation can run at any given time. If a new view-transition is initiated while another is in progress, the ongoing transition is canceled. While there's a proposal for Scoped View Transitions, it's not yet available.
From the service's perspective, adding configuration options to handle this could be considered. For instance, the run
method could accept an options argument to define how an incoming animation should behave: (1) cancel the previous animation, (2) be skipped, or (3) wait for the previous animation to complete.
While exploring these implementations could be valuable, there's another crucial scenario that needs to be discussed.
⏱️ A Case For Optimizing the Overlapping Transitions
Let's reduce the timeout for starting the triangle animation from 700ms
to 2ms
. Now, clicking the "Toggle" button will likely still result in the second animation canceling the first. However, a different console output might be seen:
start transition
start transition
state change
rendered
state change
rendered
The createRenderPromise()
method takes a small amount of time to complete, potentially slightly more than 2ms on some systems. This means that both view transitions are being started before Angular has a chance to render the changes from the first state update. This presents an opportunity for optimization. Below is a visualization of the timing of view transitions within the Angular rendering cycle:
- Initial Snapshot (Yellow): This occurs when
document.startViewTransition
is called. It likely involves capturing the initial snapshot of the relevant DOM elements. - State Change and Render (Blue): The view transition callback is executed. Here, the Angular state is updated, which triggers DOM changes. Then the render promise waits for Angular to reflect these changes in the DOM.
- Final Snapshot (Yellow): Once the callback completes, the browser takes the final snapshot of the DOM.
- Animation (Red): Finally, the browser performs the view transition animation.
In the initial scenario with a 700ms delay, the timeline of the two view transitions looked like this:
However, with the 2ms delay, the timeline looks like this:
As seen above, there is an overlap in the blue blocks where state changes are applied. In this case, a second view transition doesn't need to be initiated. Instead, the second state change should ideally be applied as part of the first view transition since Angular hasn't finished rendering yet:
This might seem like a minor edge case, but it's a critical aspect of effectively handling view transitions in Angular.
To address this, modify the run
method in the ViewTransitionService
to:
- Buffer each incoming
stateChangeFn
without immediately executing it. - If no view transition is currently active, start one. Within the view transition callback, execute all the buffered
stateChangeFn
in order. - If a view transition is already in progress:
- if Angular did not complete rendering cycle yet, execute the buffered
stateChangeFn
functions - if Angular completed the rendering cycle and View Transition API started the animation, schedule another view transition
.run
call once the current view transition completes
- if Angular did not complete rendering cycle yet, execute the buffered
Use a buffer to ensure that the state change functions aren't lost in case they come after Angular has completed the rendering cycle for the current view transition. This also ensures that state change functions are executed in the order they were received (FIFO).
Find a simplified version of the ViewTransitionService
with these changes applied on StackBlitz.
The code for this is becoming quite extensive, so for the remaining examples, I'll primarily provide references to StackBlitz for longer code snippets.
With this updated service, pressing the "Toggle" button should now execute all animations smoothly:
🛠️ Refining the API: Moving Closer to the DOM
While the ViewTransitionService
is functional, the developer experience can be further improved. Currently, every time something needs to be animated, the corresponding state change needs to be wrapped within viewTransitionService.run(() => { /* ... */ })
. This can lead to scattering rendering logic throughout the application's business logic, making it less maintainable. Ideally, there should be a way to mark elements for view transitions directly within the DOM structure. Something like this:
<ng-container *vt="stateProp">
<div style="view-transition-name: box;">
Element to animate - {{stateProp}}
</div>
</ng-container>
There needs to be a directive that observes changes to the provided stateProp
. Right before the state changes, call viewTransitionService.run(() => { /* ... */ })
to allow the View Transition API to capture the "start" snapshot. The callback of the run method would then contain the actual state update.
🤔 The Challenge of Timing: Capturing the "Before" State
This raises the question of how to intercept the moment precisely before the state change is reflected in the DOM. Angular's ngOnChanges
lifecycle hook is executed after the property has already changed, making it too late to reliably capture the initial state for the view transition.
Younes Jaaidi has conducted remarkable experiments exploring ways to intervene between the state change and the DOM update in Angular. However, his solution involves monkey-patching private Angular APIs. While his approach would provide a cleaner directive API - our API will opt for a less experimental approach.
The directive will rely on an extra piece of state that will be responsible for modifying the DOM after viewTransitionService.run
is invoked.
✨ Introducing the *vt Directive: A Custom Rendering Strategy
Here's a basic implementation of such a directive:
@Directive({
selector: '[vt]',
standalone: true,
})
export class ViewTransitionRenderer<T> {
private viewTransitionService = inject(ViewTransitionService);
private document = inject<DocumentWithViewTransition>(DOCUMENT);
private templateRef = inject(TemplateRef);
private viewContainerRef = inject(ViewContainerRef);
private cdr = inject(ChangeDetectorRef);
trackingData = input.required<T>({ alias: 'vt' });
private context: Context<T> = createContext(null as any);
ngOnChanges(changes: Changes) {
const shouldAnimate = !changes.trackingData.firstChange;
// assign these variables outside of the callback to avoid closure issues
const firstChange = changes.trackingData.firstChange;
const currentValue = changes.trackingData.currentValue;
if (!this.document.startViewTransition || !shouldAnimate) {
this.render(firstChange, currentValue);
return;
}
this.viewTransitionService.run(() => {
this.render(firstChange, currentValue);
});
}
private render(isFirstChange: boolean, trackingData: T) {
this.context.$implicit = trackingData;
if (isFirstChange) {
this.viewContainerRef.createEmbeddedView(this.templateRef, this.context);
}
this.cdr.detectChanges();
}
}
interface Context<T> {
$implicit: T;
}
function createContext<T>(data: T): Context<T> {
return { $implicit: data };
}
In this directive, the trackingData
input holds the value that will be rendered when the view transition is ready to proceed – specifically, within the callback of the viewTransitionService.run
method.
Inside the .run
callback, the input's value is assigned to the $implicit
context of the directive.
The expectation is that the template within the *vt
directive will now use the directive's implicit context variable instead of the original state property passed to the directive.
<ng-container *vt="stateProp; let state">
<div style="view-transition-name: box;">
Element to animate - {{state}}
</div>
</ng-container>
Notice the usage of {{state}}
instead of {{stateProp}}
in the template.
A slightly more convenient syntax can be used by re-assigning the same name as the original property to the $implicit
context variable:
<ng-container *vt="stateProp; let stateProp">
<div style="view-transition-name: box;">
Element to animate - {{stateProp}}
</div>
</ng-container>
This approach works, although its suitability as a best practice might be debatable. Thoughts?
With this directive in place, the demo component can be updated to leverage the directive:
@Component({
selector: 'app-root',
imports: [ViewTransitionRenderer],
template: `...`,
})
export class App {
position = signal<'left' | 'right'>('left');
isCircleVisible = computed(() => this.position() === 'right');
isTriangleVisible = signal<boolean>(false);
toggle() {
this.position.set(this.position() === 'left' ? 'right' : 'left');
setTimeout(() => {
this.isTriangleVisible.set(!this.isTriangleVisible());
}, 2);
}
}
Template:
<button (click)="toggle()">Toggle position</button>
<div class="box-container">
<ng-container *vt="position(); let position">
<div class="box" [class]="position" style="view-transition-name: box">
<span>{{ position }}</span>
</div>
</ng-container>
</div>
<ng-container *vt="isCircleVisible(); let isCircleVisible">
@if (isCircleVisible) {
<div class="circle" style="view-transition-name: circle"></div>
}
</ng-container>
<ng-container *vt="isTriangleVisible(); let isTriangleVisible">
@if (isTriangleVisible) {
<div class="triangle" style="view-transition-name: triangle"></div>
}
</ng-container>
See this fully implemented on StackBlitz.
Below is a diagram showing the data flow between the consuming component, the *vt
directive, the ViewTransitionService
and the View Transition API:
🎯 Fine-Grained Control: Enabling View Transitions on Demand
Let's modify the demo to trigger the animation for the triangle element separately:
<button (click)="toggleBox()">Toggle Box</button>
<button (click)="toggleTriangle()">Toggle Triangle</button>
toggleBox() {
this.position.set(this.position() === 'left' ? 'right' : 'left');
}
toggleTriangle() {
this.isTriangleVisible.set(!this.isTriangleVisible());
}
Now, open Chrome Dev Tools > Animations
, click the "Pause" button, and then click "Toggle Triangle." Navigate to the "Elements" tab and inspect the active view transitions. See something like this:
::view-transition
::view-transition-group(box)
::view-transition-group(circle)
::view-transition-group(triangle)
This indicates that even though only the triangle is intended to be animated, the box and circle are also part of the view transition. While their state might remain unchanged, this can have several drawbacks:
- The timing of other animations could influence the view transition timing of the intended element.
- Having numerous active animation simultaneously might lead to interference between them.
- Debugging animations becomes more complex when all of them are active by default.
A mechanism is needed to selectively enable or disable view transitions based on the intent. This mechanism should be automated as much as possible.
In the current demo, all three elements are always included in the view transition because they always have a view-transition-name
CSS property set. To disable a view transition on an element, this property needs to be set to none
.
Create another directive that works in conjunction with the *vt
directive. This directive will allow the desired view-transition-name
value to be specified only when an animation is active:
<ng-container *vt="isTriangleVisible(); let isTriangleVisible">
@if (isTriangleVisible) {
<div class="triangle" vtName="triangle"></div>
}
</ng-container>
By default, this directive will apply the value none
to the view-transition-name
style. However, when the parent *vt
directive initiates a view transition, the vtName
directive will apply the specified value. Once the view transition completes, it will revert back to none
.
I won't include the full implementation code for this directive here to keep the article concise. If interested in the implementation details - find them on Github.
🧩 Advanced Use Cases: Animating an Item in the Lists and Customizing Transitions
Before concluding, consider another common scenario.
Imagine a list of cards displayed in a column, driven by a for
loop. Each card has "up" and "down" buttons to move it within the list:
@Component({
// ...
})
export class Cards {
cards = signal<number[]>([0, 1, 2, 3]);
up(id: number)) {
this.cards.update(move(id, 'up'));
}
down(id: number)) {
this.elements.update(move(id, 'down'));
}
}
@for (card of cards(); track card) {
<div class="card">
<button (click)="up(card)">Up</button>
<button (click)="down(card)">Down</button>
</div>
}
To achieve a basic animation of card movement, wrap the container in the *vt
directive and apply the [vtName]
directive to each card:
<ng-container *vt="cards(); let cards">
@for (cardId of cards; track cardId) {
<div class="card" [vtName]="'card-' + cardId">
<button (click)="up(cardId)">Up</button>
<button (click)="down(cardId)">Down</button>
</div>
}
</ng-container>
Running this code will produce the expected animation:
However, what if there is a requirement to customize the animation of the specific card that was clicked? That element's view transition can not be easily targeted because all cards have dynamically generated view-transition-name
values:
::view-transition-image-pair(???) {
animation: size-up-and-down ease-in 0.5s;
}
There needs to be a way to dynamically set a custom view-transition-name
on the clicked element.
A new property and a method could be introduced in the ViewTransitionService
:
activeViewTransitionNames = signal<string[] | null>(null);
setActiveViewTransitionNames(...ids: string[]) {
this.activeViewTransitionNames.set(ids);
}
This method would be expected to be called right before the view transition starts. Once the transition finishes, the activeViewTransitionNames
would be reset:
this.currentViewTransition.finished.finally(() => {
this.activeViewTransitionNames.set(null);
});
With this in place, a new directive: [vtNameForActive] can be introduced. This directive would check if the activeViewTransitionNames
signal contains the name provided to the [vtName]
directive on the same element. If it does, it would apply a specific view-transition-name (e.g., 'target-card'):
<div
class="card"
[vtName]="'card-' + cardId"
[vtNameForActive]="'target-card'"
>
</div>
The Cards component would then be updated as follows:
export class Cards {
private viewTransitionService = inject(ViewTransitionService);
cards = signal<number[]>([0, 1, 2, 3]);
up(id: number)) {
this.viewTransitionService.setActiveViewTransitionNames(`card-${card.id}`);
this.cards.update(move(id, 'up'));
}
down(id: number)) {
this.viewTransitionService.setActiveViewTransitionNames(`card-${card.id}`);
this.elements.update(move(id, 'down'));
}
}
Now, target the clicked element with a specific CSS rule:
::view-transition-image-pair(target-card) {
animation: size-up-and-down ease-in 0.5s;
}
Again, the implementation details of the [vtNameForActive]
directive are omitted for brevity. Find its implementation within the @ngspot/view-transition package on Github.
The full code for making this cards animation can be found here.
📦 Further Abstraction: The @ngspot/view-transition Package

In this article there was a deep dive into challenges of integrating View Transition API with Angular. All the services and directives mentioned here can be found in the @ngspot/view-transition Package.
This package provides several more convenient directives to simplify handling various edge cases. Two notable directives are [vtNameForRouting]
and [vtNameForRouterLink]
, which streamline the use of view transitions during route navigation.
Explore the various demos showcasing the animations made possible in Angular through the View Transition API and the @ngspot/view-transition
package.
As a final thought, I encourage the amazing Angular community to try out this package and provide feedback. I'm confident that there are still many undiscovered challenges and edge cases when integrating the View Transition API with Angular. By collaborating on the API and the tools within the @ngspot/view-transition
package, we can collectively simplify this powerful integration! 🙌
