← Back
[W] Writing

How a page morphs into the next

A thumbnail grows into a header image. A title slides up and gets bigger. It looks like the browser is morphing one element into another.

It isn't. Slow it down, turn on x-ray, and watch what's really moving. Try it below.

Open an album with slow motion and x-ray on. The dashed box is what moves. Inside it, two pictures crossfade. Then try it with the aspect ratio fixed.

Two pictures in a moving box

When you call document.startViewTransition(), the browser does four things in order.

  1. Capture the old state A picture of each named element
  2. Run your update Rendering is paused
  3. Capture the new state Live, not a picture
  4. Animate a pseudo-element tree Then remove it
The old view is a snapshot taken before your callback runs. The new view is the real page, drawn live into the overlay.

First, it takes a snapshot of every element that has a view-transition-name, plus the whole page, which gets the name root by default. Then it calls your function, which changes the DOM however it likes. Then it looks at the new DOM and finds the elements with the same names.

For each name, it now has an old picture and a new view. It builds a tree of pseudo-elements on top of the page, animates it, and removes it when the animation ends.

thumb.style.viewTransitionName = "vt-thumb";

document.startViewTransition(() => {
  showDetail(album); // the new DOM has its own element named vt-thumb
});

That's all the demo does. Two elements that share a name are treated as the same thing, even though they're different DOM nodes.

The tree the browser builds

This is the structure that exists for the length of the transition. Switch direction to see how it changes when an element only exists on one side.

The tree for the demo above. Every name gets a group, an image pair and up to two images.

Each part has one job, according to the spec's default style sheet.

  • ::view-transition is an overlay that covers the page.
  • ::view-transition-group(name) is a box that starts at the old element's size and position, and animates to the new one's. It animates width, height and a transform.
  • ::view-transition-image-pair(name) holds the two images and isolates their blending, so the crossfade doesn't mix with the page beneath.
  • ::view-transition-old(name) and ::view-transition-new(name) are the two images. Old fades from 1 to 0, new fades from 0 to 1, and both use mix-blend-mode: plus-lighter so the overlap doesn't dip in brightness halfway.

The default duration is 0.25s, set on the group. That's why slow motion in the demo is one rule:

::view-transition-group(*) {
  animation-duration: 2.5s;
}

If an element only exists in the new state, its pair has only a ::view-transition-new. It fades in, with nothing to crossfade from. If it only exists in the old state, it fades out.

Why text stretches and images spill

So a "morph" is a moving, resizing box containing a crossfade of two pictures. Every odd thing you'll see comes from that.

The old image is a picture, not live content. When the group doubles in width, the picture doubles too. The text inside it scales like a photo of text. It gets bigger and a little soft, and it doesn't rewrap. That's why a title that changes size looks fine, but a paragraph that changes width looks wrong. Its lines would wrap differently at the new width, and a picture can't rewrap.

By default, each image fills the group's width and keeps its own aspect ratio. In the demo, the square thumbnail grows into a wide banner. The old square image is scaled to the banner's width, so it becomes far taller than the box and spills out underneath as it fades. The new wide image starts as a thin strip inside a square box.

The fix is to make both images fill the box and crop. The overflow: clip matters: without it, the cropped-away part still paints outside the box.

::view-transition-old(vt-thumb),
::view-transition-new(vt-thumb) {
  height: 100%;
  overflow: clip;
  object-fit: cover;
}

Turn on "Fix aspect ratio" in the demo to see it. The images are treated as replaced content, like an <img>, so object-fit works on them.

For text, give the element width: fit-content. Otherwise a heading in a full-width block captures as a full-width picture with lots of empty space, and the box animates that space too. The demo's titles use it.

Rendering is paused while you update

Between the first snapshot and the new state, the browser stops drawing the page. Your update callback can return a promise, and the transition waits for it. The whole time, the user sees a frozen frame.

So keep the update fast. Fetch data before you call startViewTransition(), not inside it. If the callback takes too long, the browser gives up after an implementation-defined timeout and skips the animation.

Names have one more rule. Each view-transition-name has to be unique on the page at the moment of capture. If two elements share a name, the browser skips the whole transition. That's why the demo names only the clicked album's thumbnail, not all of them. When you do want many elements to share one style, give each a unique name and a shared view-transition-class, then style ::view-transition-group(*.card).

Across pages, with no JavaScript

Everything above also works between two separate HTML pages. Add this to the CSS of both:

@view-transition {
  navigation: auto;
}

On a same-origin navigation, the browser snapshots the old page, loads the new one, and runs the same tree between them. The navigation has to be a normal link click or a back and forward, not a URL typed into the address bar. Two events, pageswap on the old page and pagereveal on the new one, let you set names or types at the last moment, such as naming only the thumbnail that was clicked.

Types let one stylesheet handle different transitions. Pass types: ["forward"] to startViewTransition(), or list them in the @view-transition rule, then match them with :active-view-transition-type(forward).

Support as of November 2026, from caniuse and MDN's compatibility data:

Same pageChrome and Edge 111, Safari 18, Firefox 144
view-transition-classChrome 125, Safari 18.2, Firefox 144
TypesChrome 125, Safari 18.2, Firefox 147
Across pagesChrome and Edge 126, Safari 18.2. Firefox only in Nightly.
Cross-document transitions are a focus area of Interop 2026, so the Firefox row may change soon. Browsers without support just navigate normally.

That last point is the nice part. A view transition is an enhancement by design. If a browser can't do it, the page still changes, just without the motion.

Reduced motion

A box flying across the screen is the kind of motion that bothers people with vestibular disorders. Wrap your transition styles in @media (prefers-reduced-motion: no-preference), or check the media query before calling startViewTransition(), as the demo does.

That doesn't have to mean no transition at all. A short crossfade with no movement still tells people the view changed, without anything travelling across their screen.

Once you know the morph is two pictures in a moving box, you stop fighting the browser. You decide which things deserve a name, give them boxes that keep their shape, and let everything else crossfade.

Next