In an Angular app using withViewTransitions(), how do you morph a gallery thumbnail into the photo-detail image, and where must the CSS live?
answer
- same name on both pages
- names must be unique
- old and new pseudo-elements
- encapsulation blocks pseudo-element rules
basics
~20 sGive the thumbnail and the detail image the same view-transition-name, unique on each page, for example bound per photo id. The browser pairs them and morphs one into the other. The ::view-transition-* animation rules go in global styles, because encapsulated component styles cannot reach them.
solid answer
~40 sWith `withViewTransitions()` on, the browser captures the gallery and the detail page. Any element with a `view-transition-name` is captured separately, and elements with the **same** name in the old and new state are animated as one: position, size and a cross-fade of their images. So I bind `[style.view-transition-name]="'photo-' + photo.id"` on each thumbnail and the same expression on the detail image. They need not be the same DOM element, but each name must be unique on the page at capture time, or the browser skips the transition. The rest of the page keeps the default cross-fade under the `root` name. Timing and easing are set on `::view-transition-group(photo-7)` or with wildcard selectors, in the global stylesheet: Angular's emulated encapsulation scopes component styles, so pseudo-element rules written there never match.
code
ts · 36 linesimport {Component, input} from '@angular/core';
import {RouterLink} from '@angular/router';
interface Photo {
id: string;
thumbUrl: string;
fullUrl: string;
title: string;
}
@Component({
selector: 'app-gallery',
imports: [RouterLink],
template: `
@for (photo of photos(); track photo.id) {
<a [routerLink]="['/photos', photo.id]">
<img [src]="photo.thumbUrl" [alt]="photo.title" [style.view-transition-name]="'photo-' + photo.id" />
</a>
}
`,
})
export class Gallery {
readonly photos = input.required<Photo[]>();
}
@Component({
selector: 'app-photo-detail',
template: `<img class="hero" [src]="photo().fullUrl" [alt]="photo().title" [style.view-transition-name]="'photo-' + photo().id" />`,
})
export class PhotoDetail {
readonly photo = input.required<Photo>();
}
/* styles.css (global, not component styles):
::view-transition-group(*) { animation-duration: 250ms; }
*/go deeper
Recall that elements sharing a view-transition-name across the two pages are morphed, and that the CSS for the pseudo-elements goes in global styles.
Explain pairing by name, the uniqueness rule, dynamic names bound from data, and why encapsulation stops pseudo-element rules in component styles.
Anticipate failures such as duplicate names, capture cost with many named elements, late-loading images and reduced-motion needs.
Define conventions for naming shared elements across feature teams so transitions stay unique and consistent in a large routed app.
## The scenario A photo gallery shows a grid of thumbnails at `/photos`. Clicking one navigates to `/photos/:id`, where the same photo is shown large. Instead of the whole page cross-fading, the design wants the clicked thumbnail to **grow into** the large image, while everything else fades. The router's view-transition integration makes this a matter of naming elements and writing CSS. ## How the browser pairs elements When the router starts a view transition, the browser captures every element that has a CSS `view-transition-name` as its own layer, in addition to the page as a whole (named `root`). After the router has swapped the routed components, it captures the new state the same way. Then: | In old state | In new state | Result | |---|---|---| | name `photo-7` | name `photo-7` | one group, animated from old size and position to new, with a cross-fade of the two images | | name `photo-3` | absent | the old image animates out (fades by default) | | absent | name `caption` | the new element animates in | | everything else | everything else | part of `root`, cross-faded | The two elements **do not need to be the same DOM element**, and in a route change they never are: the thumbnail belongs to the gallery component, the large image to the detail component. The shared name is the only link. ## Naming rules that matter 1. **Unique per state.** At the moment of capture, no two rendered elements may share a name. If they do, the browser abandons the view transition and the navigation completes without animation. 2. **Dynamic names.** Binding the name from data, `[style.view-transition-name]="'photo-' + photo.id"`, keeps every thumbnail unique while still matching the detail image for the same id. 3. **Cost.** Every named element becomes a separately captured layer. Naming hundreds of thumbnails is legal but makes each capture more expensive; an alternative is to set the name only on the clicked thumbnail just before navigating. 4. **Visibility.** Only elements that are rendered at capture time take part; a thumbnail scrolled out of a virtual list cannot morph. ## Where the CSS goes The browser builds a tree of pseudo-elements on the document root: `::view-transition-group(name)`, containing `::view-transition-old(name)` and `::view-transition-new(name)`. Rules that customise them, such as a duration on the group or a custom keyframe on the new image, **must live in the global stylesheet** (`styles.css`). Angular's default emulated encapsulation rewrites every selector in a component's `styles` with an attribute that only that component's elements carry. The pseudo-elements are not component elements, so an encapsulated rule never matches them. Setting `view-transition-name` itself is different: it is an ordinary property on an element, so a binding or a component style rule on that element works. ## Putting it together - Enable the integration with `provideRouter(routes, withViewTransitions())`. - Bind the same dynamic name on the thumbnail and on the detail image. - In global styles, tune the morph: `::view-transition-group(*) { animation-duration: 250ms; }` changes every group, or target one name for finer control. - Give the back navigation the same treatment for free: going from detail to gallery reverses the pairing, so the large image shrinks back into its thumbnail. ## Accessibility and focus A morph changes what is on screen but not where keyboard focus is. After the navigation, move focus to the detail view's heading or main landmark as you would for any route change; focus management and the view transition are independent, and the animation should never be the only cue that the page changed. ## Things that go wrong - **Duplicate names:** a gallery that shows the same photo twice (say, in a "recent" strip and the grid) breaks the transition entirely. - **Styles in the component:** the morph works but ignores the tuned duration, because the pseudo-element rules were written in a component stylesheet. - **Layout shifts during capture:** if the detail page renders a placeholder and the image arrives later, the new state is captured with the placeholder. The router does not wait for extra content, so pre-load the data with a resolver or keep the placeholder the same size. - **Reduced motion:** a morph is exactly the kind of large motion users who prefer reduced motion want removed; wrap the custom rules in the appropriate media query.
- The morph works, but the duration set in the gallery component's styles is ignored. Why?Emulated encapsulation adds a component-specific attribute to every selector in that component's styles. `::view-transition-group(...)` pseudo-elements hang off the document root and never carry that attribute, so the rule does not match. Moving it to the global stylesheet fixes it.
- The same photo appears in a 'recent' strip and in the grid. What happens on navigation?Both copies get the same `view-transition-name`, so the old state has a duplicate name. The browser treats that as invalid and skips the view transition; the navigation still completes, just without animation. Name only one copy, for example only the grid thumbnail, or set the name on the clicked element.
saying these in an interview costs you the question
- The thumbnail and the detail image must be the same DOM element.
- Several elements on one page may share a view-transition-name.
- View-transition pseudo-element rules can live in component styles.
- Angular's @angular/animations engine performs the morph.
- The router waits for the detail image to load before capturing.