Customising Old and New Snapshot Animations

Part of View Transition Types & Nested Groups in Modern View Transitions & Scroll APIs.

The problem

A photo gallery uses a view transition to morph a thumbnail into a full-size image. The default cross-fade works, but design wants the old thumbnail to stay fully visible while the new image scales and fades in over it. The first attempt adds a custom fade keyframe to ::view-transition-new — and the result is a strange mid-transition flash where the image looks brighter than either snapshot. A second attempt changes the old snapshot’s animation and the midpoint looks washed out. Elsewhere, a card morphing into a wide banner stretches its text.

The default animation is more carefully constructed than it looks. Customising it well means knowing the parts.

Root cause analysis: a group, a pair and two images

For every named element, the transition builds a small tree:

  • ::view-transition-group(name) animates position and size from the old element’s box to the new one’s, using transform and width/height.
  • ::view-transition-image-pair(name) sits inside the group with isolation: isolate, so the two images blend only with each other.
  • ::view-transition-old(name) and ::view-transition-new(name) are replaced elements showing the captured images. By default they fade out and fade in.

The default cross-fade uses plus-lighter. Both images have mix-blend-mode: plus-lighter. With normal blending, two images at 50% opacity on top of each other composite to less than full opacity where they overlap, so a standard cross-fade looks dim or see-through at the midpoint. plus-lighter adds the colour contributions instead, so at the midpoint the overlapping opaque pixels sum to full opacity. The isolation on the image pair keeps that addition from brightening what is behind the group.

Custom fades that fight the blend look wrong. If you replace the new image’s animation with one that fades in while the old image stays at full opacity, plus-lighter adds a full-opacity old image to a partially opaque new one and the overlap over-brightens — the flash. When the old image should stay solid, set mix-blend-mode: normal on both, or fade the old image out as the new one fades in so the contributions still sum to one.

Snapshots are sized to the group. By default both images fill the group box as it animates. When the aspect ratio changes, filling means stretching. object-fit and explicit block and inline sizes on the snapshots control this, as the debugging guide shows in diagnosing stretched content.

Group and images have separate timing. The group’s movement and the images’ fade are different animations on different pseudo-elements, so each can have its own duration and easing.

The pseudo-element tree for one named elementCustomise each layer for what it controls.The pseudo-element tree for one named element::view-transition-new(photo)Incoming image; default fade in, plus-lighter::view-transition-old(photo)Outgoing image; default fade out, plus-lighter::view-transition-image-pair(photo)isolation: isolate::view-transition-group(photo)Position and size animation
Customise each layer for what it controls.

Step-by-step resolution

A thumbnail-to-photo revealOld stays solid, new scales in, no brightness flash.A thumbnail-to-photo reveal1Give the thumbnail and the full photo the same view-transition-name.One group morphs between them2Set mix-blend-mode: normal on both snapshots.No additive brightening3Keep the old snapshot's animation as none so it stays opaque.Thumbnail visible underneath4Give the new snapshot a scale and fade-in animation.Photo emerges over the thumbnail5Set object-fit: cover on both snapshots.No stretching across aspect ratios6Under reduced motion, remove the custom animations.
Old stays solid, new scales in, no brightness flash.

Production code pattern

.gallery-thumb, .photo-view__image {
  view-transition-name: photo;
}

::view-transition-group(photo) {
  animation-duration: var(--motion-move-duration);
  animation-timing-function: var(--motion-move-easing);
}

::view-transition-old(photo),
::view-transition-new(photo) {
  mix-blend-mode: normal;             /* old stays solid under the new image */
  block-size: 100%;
  inline-size: 100%;
  object-fit: cover;                  /* crop instead of stretch as aspect ratio changes */
}

::view-transition-old(photo) {
  animation: none;                    /* hold the thumbnail at full opacity */
}

::view-transition-new(photo) {
  animation: photo-in var(--motion-enter-duration) var(--motion-enter-easing) both;
}

@keyframes photo-in {
  from { opacity: 0; scale: 1.04; }
}

/* A conventional cross-fade with different timing: keep plus-lighter. */
::view-transition-old(title) { animation-duration: 120ms; }
::view-transition-new(title) { animation-duration: 200ms; animation-delay: 60ms; }

@media (prefers-reduced-motion: reduce) {
  ::view-transition-group(photo),
  ::view-transition-old(photo),
  ::view-transition-new(photo) { animation: none; }
}

Rendering Impact: composite. Snapshot images are textures, and their opacity and scale animations run on the compositor; the group’s size animation may involve main-thread work, which is why keeping the number of named groups small matters more than the choice of keyframes.

The title example changes only durations and delay, leaving the default fade keyframes and plus-lighter untouched. Offsetting the fades slightly does introduce a moment where contributions do not sum to one, but for text on a solid background over 60ms it is invisible — test it against your own content.

Holding the old image while the new fades inThe blend mode decides whether the overlap stays correct.Holding the old image while the new fades inDefault plus-lighter keptOld image at full opacityNew image adds its colour on topOverlap brighter than either imageMid-transition flashmix-blend-mode: normalOld image at full opacityNew image composites over it normallyOverlap never exceeds either imageClean reveal
The blend mode decides whether the overlap stays correct.

Transitions for the root

The same pseudo-elements exist for root, the whole-page snapshot. Page-level customisations — slides, wipes, scale-and-fade — follow the same rules: override ::view-transition-old(root) and ::view-transition-new(root) animations, keep plus-lighter for true cross-fades, and switch to normal when one page should cover the other. Direction-aware versions select animations with types, as in choosing types for route direction. Because the root snapshot is the full viewport, custom root animations that scale the snapshot up can soften text; scale down into place instead.

Verification checklist

Constraints and trade-offs

  • Changing mix-blend-mode to normal makes true cross-fades look dim at the midpoint.
  • object-fit: cover crops snapshots; content near the edges can be hidden mid-transition.
  • Custom keyframes on many groups add complexity to every transition that uses those names.
  • Snapshot images are raster captures; scaling them up blurs text.
  • Some pseudo-element properties are set by user-agent styles that differ slightly between engines.

Frequently asked questions

Why does my custom view transition fade look brighter in the middle?

The snapshots use mix-blend-mode: plus-lighter by default, which adds their colours. If the old image stays opaque while the new fades in, the overlap over-brightens. Use mix-blend-mode: normal for that effect.

How do I stop the old snapshot fading out?

Set animation: none on ::view-transition-old(name). It then stays at full opacity until the transition ends.

Why is the default cross-fade not dim at 50%?

Because plus-lighter sums the contributions of the two images, so overlapping opaque pixels add up to full opacity instead of compositing to partial opacity.

How do I stop snapshots stretching?

Set object-fit and 100% block and inline size on the old and new pseudo-elements so they crop or fit within the animating group instead of filling it.