My route changes crossfade now, and I wrote zero animation code
withViewTransitions() hands every navigation to the browser's View Transitions API. One router flag, a couple of CSS names for the fancy parts, and a graceful nothing in browsers that don't support it.
Page-transition animations in Angular used to mean the full @angular/animations liturgy: a trigger on the router outlet, query(':enter') and query(':leave') incantations, absolute-positioning both pages during the overlap so the layout doesn't jump, and a prayer. I shipped exactly one of those in my career, and I remember the CSS more than the feature.
The browser can just do this now. The View Transitions API snapshots the old DOM, swaps in the new one, and animates between the two snapshots on the compositor. Angular's router plugs into it with one flag:
provideRouter(
routes,
withViewTransitions(),
)That's the whole baseline. Every navigation is now wrapped in document.startViewTransition(), and you get a quick crossfade between routes without touching a single component. In a browser without the API, the call is skipped and navigation behaves exactly as before — no fallback branch to write, no feature detection. It is progressive enhancement in the original sense of the term.
The shared-element trick
The crossfade is nice; the morph is the party piece. Give an element on the outgoing page and an element on the incoming page the same view-transition-name, and the browser animates one into the other — position, size, the lot:
/* list page */
.product-card__image { view-transition-name: product-hero; }
/* detail page */
.product-detail__hero { view-transition-name: product-hero; }Click a card, and its thumbnail glides into place as the detail hero. This is the effect native apps have lorded over the web for a decade, and it's two lines of CSS. One rule to respect: a view-transition-name must be unique on the page at transition time. For a list of cards that means assigning the name dynamically to the clicked card only — bind a style on click, or you'll get a console warning and no animation.
Customizing without an animation library
The snapshots are exposed as pseudo-elements, so the timing and easing are plain CSS:
::view-transition-old(root) {
animation: 150ms ease-out both fade-slide-out;
}
::view-transition-new(root) {
animation: 200ms ease-in both fade-slide-in;
}Slide-ins, directional wipes, staggered headers — it's keyframes on pseudo-elements, not a TypeScript DSL. The root argument targets the whole page; named transitions get their own pair.
Skipping when you should
Two navigations deserve no theatrics: back/forward (users expect instant), and anything under prefers-reduced-motion. The router hands you the hook:
withViewTransitions({
onViewTransitionCreated: ({ transition }) => {
const reduced =
matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduced) transition.skipTransition();
},
})skipTransition() aborts the animation but not the navigation. Reduced motion isn't a nice-to-have flourish — vestibular users report actual nausea from full-page movement, so treat the media query as a contract.
The honest caveats
First, support: Chromium and Safari have it; if a visitor's browser doesn't, they get an ordinary instant navigation — acceptable by design. Second, the API snapshots the page, and on a very long, very busy DOM that snapshot isn't free; if a route is heavy, a janky transition is worse than none, and skipTransition() is your friend. Third, don't mix systems: router-outlet animations from @angular/animations and view transitions both want to own the same moment, and they fight. I deleted the old trigger entirely. The browser compositor does this job better than a JavaScript animation engine ever did — and the code I got to remove was exactly the code I never wanted to maintain.