Angular Route Transition Animations with the View Transitions API [2026]
Route animations used to be one of the most involved things you could build in Angular. You wrapped the <router-outlet> in a trigger, tagged routes with animation data, and wrote query(':enter'), query(':leave') and group() sequences that positioned two pages on top of each other while they animated. The browser now does most of that work: the View Transitions API snapshots the old page, lets the framework update the DOM, and animates between the two. Angular's router integrates with it in one line.
This is lesson 13.3 of the Angular Tutorial. It builds on lesson 13.2, which covered enter, leave and state animations with CSS, and on the router essentials from Module 5. You'll learn how withViewTransitions() works, how to style transitions with the ::view-transition-* pseudo-elements, how to animate shared elements between pages with view-transition-name, how to customise or skip transitions with onViewTransitionCreated, and how to handle browser support and reduced-motion preferences.
How view transitions work #
When a navigation happens with view transitions enabled, the router runs the route deactivation and activation inside document.startViewTransition(). The browser then:
| Step | What happens |
|---|---|
| 1. Capture | The browser takes a snapshot of the current page (and of every element with a view-transition-name) |
| 2. Update | Angular deactivates the old route and renders the new one; the DOM changes as usual |
| 3. Capture again | The browser records the new state of the page and named elements |
| 4. Animate | It builds a tree of pseudo-elements showing the old and new snapshots, and animates between them (a cross-fade by default) |
| 5. Clean up | The pseudo-elements are removed and the real new page is shown |
Your components don't animate anything. The animation happens on snapshots, in a layer above the page, which is why one line of configuration gives you a working default.
Enabling it #
// app.config.ts
import { ApplicationConfig } from '@angular/core';
import { provideRouter, withViewTransitions } from '@angular/router';
import { routes } from './app.routes';
export const appConfig: ApplicationConfig = {
providers: [provideRouter(routes, withViewTransitions())],
};
Every router navigation now cross-fades. If the browser doesn't support the View Transitions API, the router skips it and performs the navigation as usual, with no error and no animation, so this is safe to turn on everywhere.
The router's view transition integration is marked developer preview in the Angular documentation, so its options may still change between versions. For applications that use RouterModule.forRoot, the equivalent is the enableViewTransitions: true option.
withViewTransitions() takes an options object:
| Option | What it does |
|---|---|
skipInitialTransition |
Skips the transition for the initial navigation when the app first loads |
onViewTransitionCreated |
A callback that receives the ViewTransition plus the route snapshots it's moving from and to; it runs in an injection context |
Styling the transition #
The browser creates a pseudo-element tree for each transition:
::view-transition
└─ ::view-transition-group(root)
└─ ::view-transition-image-pair(root)
├─ ::view-transition-old(root) ← snapshot of the outgoing page
└─ ::view-transition-new(root) ← live view of the incoming page
Override their animations to replace the cross-fade. These rules must go in your global stylesheet (src/styles.css), not in component styles: the pseudo-elements belong to the document, and Angular's view encapsulation would scope component styles so they never match.
/* src/styles.css */
::view-transition-old(root) {
animation: 200ms ease-in both slide-out;
}
::view-transition-new(root) {
animation: 250ms ease-out both slide-in;
}
@keyframes slide-out {
to { opacity: 0; transform: translateX(-24px); }
}
@keyframes slide-in {
from { opacity: 0; transform: translateX(24px); }
}
Keep these short. During the transition, the pseudo-element layer sits above the page, so a long transition delays the moment users can interact with the new page.
Shared-element transitions with view-transition-name #
The most striking use of view transitions is a shared element: a product image in a list that grows into the hero image on the detail page. Give the element the same view-transition-name on both pages, and the browser animates its position and size between them:
<!-- product-list.html -->
@for (product of products(); track product.id) {
<a [routerLink]="['/products', product.id]">
<img [src]="product.thumb" [alt]="product.name"
[style.view-transition-name]="'product-' + product.id" />
{{ product.name }}
</a>
}
<!-- product-detail.html -->
<img class="hero" [src]="product().image" [alt]="product().name"
[style.view-transition-name]="'product-' + product().id" />
Each view-transition-name must be unique among the elements on the page at the moment a snapshot is taken. If two visible elements share a name, the browser abandons the transition. That's why the list uses a name per product rather than one fixed name for all thumbnails. Names follow CSS identifier rules, so derive them from IDs that are safe as identifiers (letters, digits, hyphens), or prefix them as above.
You can style a single named group too, for example to slow down only the shared image:
::view-transition-group(*) {
animation-duration: 250ms;
}
Customising transitions per navigation #
onViewTransitionCreated gives you the ViewTransition object for each navigation, so you can skip it, wait for it, or mark the document so CSS can choose an animation. Because it runs in an injection context, you can call inject() inside it.
A common pattern is to let routes declare their transition style in data, and expose it to CSS as an attribute on <html> for the duration of the transition:
// app.routes.ts
export const routes: Routes = [
{ path: '', component: Home },
{ path: 'products/:id', component: ProductDetail, data: { transition: 'zoom' } },
{ path: 'settings', component: Settings, data: { transition: 'none' } },
];
// app.config.ts
provideRouter(
routes,
withViewTransitions({
onViewTransitionCreated: ({ transition, to }) => {
let route = to;
while (route.firstChild) route = route.firstChild; // deepest activated route
const kind = route.data['transition'] ?? 'slide';
if (kind === 'none') {
transition.skipTransition();
return;
}
const root = inject(DOCUMENT).documentElement;
root.dataset['transition'] = kind;
transition.finished.finally(() => delete root.dataset['transition']);
},
}),
),
/* src/styles.css */
html[data-transition='zoom']::view-transition-new(root) {
animation: 250ms ease-out both zoom-in;
}
@keyframes zoom-in {
from { opacity: 0; transform: scale(0.96); }
}
transition.skipTransition() cancels the animation but not the navigation; the DOM update still happens. Use it for navigations that shouldn't animate, such as changes to query parameters or fragments within the same page, or routes where an animation would be distracting.
Browser support and fallback #
The router only starts a view transition when document.startViewTransition exists. In other browsers, navigation works exactly as it would without the feature. That makes view transitions a progressive enhancement: design pages so they make sense without the animation, and treat the animation as polish. Support for same-document view transitions has spread across the major browser engines, but check current data, for example on MDN, against the browsers your users have before relying on a shared-element effect to communicate something important.
Accessibility #
Two concerns apply to every route animation.
Reduced motion. Users who set "reduce motion" at the operating system level should not get sliding or zooming pages. A short cross-fade is generally acceptable; movement is the problem. Handle it in CSS, which covers every transition at once:
@media (prefers-reduced-motion: reduce) {
::view-transition-group(*),
::view-transition-old(*),
::view-transition-new(*) {
animation: none !important;
}
}
Alternatively, skip the transition entirely in onViewTransitionCreated when matchMedia('(prefers-reduced-motion: reduce)').matches is true. Lesson 13.4 makes this part of a reusable setup.
Focus and announcements. A view transition is purely visual; it doesn't move focus or announce the new page to screen readers. Keep whatever focus management and page-title updates you already have for navigation.
View transitions versus legacy route animations #
| Legacy route animations | withViewTransitions() |
|
|---|---|---|
| Package | @angular/animations (deprecated) |
Router + browser API |
| Setup | Trigger on the outlet wrapper, route data, query/group choreography |
One provider, global CSS |
| Old and new pages in the DOM at once | Yes, you position them yourself | No, the browser animates snapshots |
| Shared elements | Hard to build | view-transition-name |
| Unsupported browsers | Works wherever the package runs | Navigates without animation |
| Styling location | Component metadata | Global stylesheet |
Gotchas #
| Symptom | Cause | Fix |
|---|---|---|
Custom ::view-transition-* styles ignored |
Rules in a component stylesheet | Move them to src/styles.css |
| Transition silently doesn't happen | Two visible elements share a view-transition-name |
Make names unique, for example product-<id> |
| No animation in some browsers | View Transitions API unavailable | Expected fallback; navigation still works |
| Every query-param change animates | Transition runs for all router navigations | Call transition.skipTransition() for those navigations |
| Page feels unresponsive after clicking a link | Long transition keeps the snapshot layer on top | Keep durations around 200–300 ms |
| Users with reduced motion still see slides | No reduced-motion handling | prefers-reduced-motion media query or skipTransition() |
What's next #
Lesson 13.4 closes Module 13 by making animations reusable and safe: shared CSS animation utilities, prefers-reduced-motion across the app, disabling animations in tests and on request, how animations interact with OnPush and zoneless change detection, avoiding flashes with SSR and hydration, and keeping animations on properties the browser can animate cheaply.
Try it yourself #
Add withViewTransitions() to an app's provideRouter call and navigate around to see the default cross-fade. Then pick one list-to-detail flow and give the list thumbnail and the detail image a matching view-transition-name. Test it with "reduce motion" switched on in your operating system settings, and confirm that the movement disappears.
search_documentationThe view transition pseudo-elements belong to the document root, not to any component. Angular’s view encapsulation rewrites the selectors in app.component.css with component-specific attributes, so they never match ::view-transition-old(root). Move the ::view-transition-old(root), ::view-transition-new(root) and their @keyframes into src/styles.css. While you’re there, add a @media (prefers-reduced-motion: reduce) block that sets animation: none on the view transition pseudo-elements, so the slide doesn’t play for users who’ve asked for less motion. Note that the router integration is in developer preview, so check the release notes when you upgrade.Up next in Angular
More from this topic
Enjoyed this article?
Get new Angular tutorials delivered. No spam — just code-first articles when they ship.
1 comment



Pingback: Angular Enter, Leave and State Animations with CSS [2026]