skip to content

An Angular gallery using withViewTransitions() cross-fades the whole page whenever only a ?page= query parameter changes; how do you skip those transitions?

level: seniorimportance: nice to knowfreq 18%

answer

  1. a callback per transition
  2. runs in an injection context
  3. from and to snapshots
  4. the ViewTransition object can skip

basics

~10 s

Pass onViewTransitionCreated to withViewTransitions(). It receives a ViewTransitionInfo with the browser's transition and the from and to root route snapshots; when the path is unchanged, call transition.skipTransition(). The navigation still completes, just without animation.

solid answer

~40 s

`withViewTransitions({onViewTransitionCreated})` calls my function after each `startViewTransition`, inside an injection context, so it can use `inject()`. Its `ViewTransitionInfo` argument has `transition`, the browser's `ViewTransition`, plus `from` and `to`, the **root** `ActivatedRouteSnapshot`s of the old and new router state. To detect a query-only change I can compare the matched routes and path parameters of `from` and `to`, or follow the guide's recipe: `isActive()` with `paths: 'exact'`, `queryParams: 'ignored'` and `fragment: 'ignored'` against the target URL from `router.currentNavigation()`. When the path has not changed, `transition.skipTransition()` cancels the animation while the router still activates the new state. The same hook can pick different animations per route, for example by reading route `data` on the deepest `to` child. Angular also skips the transition automatically when the browser already animates a history navigation itself.

code

ts · 29 lines
ts
import {inject} from '@angular/core';
import {bootstrapApplication} from '@angular/platform-browser';
import {isActive, provideRouter, Router, withViewTransitions} from '@angular/router';
import {App} from './app/app';
import {routes} from './app/app.routes';

bootstrapApplication(App, {
  providers: [
    provideRouter(
      routes,
      withViewTransitions({
        onViewTransitionCreated: ({transition}) => {
          const router = inject(Router);
          const target = router.currentNavigation()?.finalUrl;
          if (!target) return;
          const samePage = isActive(target, router, {
            paths: 'exact',
            matrixParams: 'exact',
            queryParams: 'ignored',
            fragment: 'ignored',
          });
          if (samePage()) {
            transition.skipTransition();
          }
        },
      }),
    ),
  ],
});

go deeper

for a junior

Know that withViewTransitions() accepts an onViewTransitionCreated option that can call skipTransition() on the transition.

for a middle

Explain ViewTransitionInfo: the browser transition plus root from and to snapshots, and that the callback runs in an injection context.

for a senior

Diagnose unwanted transitions such as query-only navigations, implement a same-route check, and verify that real route changes still animate.

for a principal

Decide which navigation classes deserve motion across the product, and encode that policy once in the router configuration.

## The symptom The gallery lists photos at `/photos?page=2`. Paging changes only the query parameter, so the router runs a navigation, and with `withViewTransitions()` enabled every navigation is wrapped in a view transition. The whole page cross-fades each time the user pages, which feels sluggish and hides the fact that only the grid changed. The fix is to **skip** transitions for navigations that do not change the route itself. ## The hook: onViewTransitionCreated `withViewTransitions()` accepts an options object. Its `onViewTransitionCreated` function runs right after the router has called `document.startViewTransition`, for every navigation that gets a transition. - It runs in an **injection context**, so it may call `inject(Router)` or inject any service. - Its argument is a **`ViewTransitionInfo`**: | Field | Type | Meaning | |---|---|---| | `transition` | `ViewTransition` | the browser object returned by `startViewTransition` | | `from` | `ActivatedRouteSnapshot` | the root snapshot of the router state being left | | `to` | `ActivatedRouteSnapshot` | the root snapshot of the router state being entered | Because `from` and `to` are the **roots** of their trees, you walk `firstChild` down to reach the leaf route whose `data`, `params` or `routeConfig` you care about. ## Deciding to skip Two common ways to detect "only query parameters or the fragment changed": 1. **The guide's recipe.** Read the target URL with `inject(Router).currentNavigation()?.finalUrl`, then call `isActive(targetUrl, router, {paths: 'exact', matrixParams: 'exact', queryParams: 'ignored', fragment: 'ignored'})`. That returns a signal that is `true` when the target matches the current URL once query parameters and fragment are ignored, which is exactly the "same page" case. 2. **Comparing snapshots.** Walk to the leaf of `from` and `to` and compare their `routeConfig` and path parameters. If both match, the route did not change. Then call **`transition.skipTransition()`**. This is the browser's own API: it cancels the animation, but the DOM update, and therefore the router's activation of the new state, still happens. The navigation completes normally. ## Other skips that happen without your code - **`skipInitialTransition: true`** skips the first transition, typically the initial load. - **Browser-provided transitions.** When a history navigation already comes with the browser's own visual transition, for example a back-swipe gesture, Angular (since 22.1.5) does not start a second one. - **No API.** Browsers without `document.startViewTransition` never get a transition at all. ## Customising instead of skipping The same hook can tune a transition rather than cancel it: - read a flag from the leaf route's `data` (say `{transition: 'slide'}`) and set a class or a view-transition type on the document so global CSS picks a different animation; - await `transition.finished` to clean up that class afterwards. Those are browser features on the `ViewTransition` object; Angular just hands it to you at the right time. ## What you cannot do The router provides **no hook to delay** the transition until more content loads, by design: the page is non-interactive while a view transition runs. If the new page needs data before it looks right, load it in a resolver so it is ready before the router starts the transition. ## Why not simply turn the feature off? Removing `withViewTransitions()` would fix the paging flicker but also lose the motion that does help: the thumbnail-to-detail morph and the cross-fade between sections. The hook lets you keep transitions for navigations that change what the user is looking at and drop them for navigations that only refine it. Typical candidates for skipping: - query-parameter changes for paging, sorting and filtering; - fragment changes for in-page anchors; - navigations triggered programmatically to sync state, such as writing the current tab into the URL. Keeping that policy in one callback, next to `provideRouter`, is easier to reason about than sprinkling special cases through components. ## Checklist for this bug 1. Confirm the navigations are query-only, for example by logging router events. 2. Add `onViewTransitionCreated` with a same-route check. 3. Call `transition.skipTransition()` when the check passes. 4. Verify that real route changes, such as gallery to photo detail, still animate. The integration and these options are still developer preview in Angular 22.2, so pin the behaviour with an end-to-end test rather than relying on it silently.

  • Inside onViewTransitionCreated, how do you read the data of the route being entered?
    `to` is the root `ActivatedRouteSnapshot` of the new state, so walk down: `let r = to; while (r.firstChild) r = r.firstChild;` and then read `r.data`, `r.params` or `r.routeConfig`. Reading `to.data` directly usually gives the root route's data, not the leaf's.
  • Does calling skipTransition() cancel the navigation?
    No. `skipTransition()` only cancels the animation. The browser still runs the update callback, so the router activates the new routes and the URL changes as usual; the user just sees the new state immediately.

saying these in an interview costs you the question

  • skipTransition() cancels the navigation along with the animation.
  • onViewTransitionCreated cannot use inject().
  • ViewTransitionInfo.to is already the leaf route's snapshot.
  • The router can delay the transition until the new page's data loads.
  • Query-only navigations are never wrapped in a view transition.