View Transition Types & Nested Groups

Part of Modern View Transitions & Scroll APIs.

A first view transition is always the same transition. document.startViewTransition() captures the page, crossfades it, and every navigation in the application looks identical — forward, backward, filter change and detail open all fade the same way. Transition types are the mechanism that fixes this: a set of identifiers passed in with the transition, matched in CSS by the :active-view-transition-type() pseudo-class, so one call site can produce direction-aware, context-aware motion without branching in JavaScript.

Nested groups solve the adjacent problem. By default the snapshot tree is flat: every named element becomes a sibling group under the transition root, which means a card that morphs into a detail panel cannot clip or mask its own children during the morph. Nesting a group inside another lets a container and its contents animate as a unit, which is what makes a genuine list-to-detail morph read as one object moving rather than several objects arriving separately.

Concept: naming the transition, not just the elements

view-transition-name answers “which elements correspond across the two states”. Types answer a different question: “what kind of change is this?”

You supply them as part of the call — document.startViewTransition({ update, types: ['forward'] }) — and they exist for exactly the lifetime of that transition. In CSS, html:active-view-transition-type(forward) matches only while a transition carrying that type is running, so a stylesheet can define one set of pseudo-element animations for forward navigation, another for backward, and a third for an in-place filter update, all keyed off the same pseudo-element tree.

That keeps the branching where it belongs. The router knows the direction; it says so once. The stylesheet knows what each direction should look like; it says so declaratively, next to the rest of the motion. Nothing in between has to translate between the two.

What each identifier is responsible forNames correspond, types classify, classes group — three separate jobs that are easy to confuse.What each identifier is responsible forAnswersLifetimeMatched byview-transition-nameWhich element is whichWhile the element isnamedPseudo-element selectorTransitiontypeWhat kind of change thisisOne transition:active-view-transition-type()view-transition-classWhich group of namesshare stylingWhile the element isclassedPseudo-element with aclassNested groupWhat moves as one objectOne transitionGroup hierarchy in thetree
Names correspond, types classify, classes group — three separate jobs that are easy to confuse.

Execution model

Types change nothing about how the transition runs; they change what the cascade matches while it runs. The capture, the pseudo-element tree, the compositor handoff and the ready/finished promises are all identical to a plain transition, as described in view transitions API implementation.

What happens is a class-like flag on the document element for the duration. Before the old snapshot is taken, the browser records the type list; while the pseudo-element tree exists, :active-view-transition-type(x) matches for every x in that list; when the transition finishes or is skipped, the flag is removed. Because it is resolved during style, matching costs one selector match per rule, not per frame — the animation itself is still compositor-sampled.

Nested groups are a structural change to the tree rather than a styling one. Normally each named element produces a ::view-transition-group() directly under ::view-transition. Declaring view-transition-group: contain (or naming a parent group explicitly) on a named element places its group inside another group’s subtree. That has two consequences worth designing around: the child inherits the parent group’s transform, so a container that scales carries its contents with it; and the parent’s clipping applies, so content that grows beyond the container is masked rather than spilling across the page.

The cost of nesting is that the child can no longer travel independently of its parent. A thumbnail that must fly from a card to a completely different part of the page has to stay a sibling group. A thumbnail that stays inside the card as the card becomes a panel should be nested — otherwise it detaches, floats over the growing container, and lands back into place at the end, which is the tell-tale “shattered morph” look.

Where types and nesting enter the transition lifecycleBoth are resolved before the animation starts; neither adds per-frame work.Where types and nesting enter the transition lifecyclestartViewTransition({types})Old snapshotcapturedType flags seton rootstyleGroup treebuiltnesting appliedType-specifickeyframes matchComposited tofinished
Both are resolved before the animation starts; neither adds per-frame work.

API and property reference

API / property Accepted values Where it lives Notes
startViewTransition({ update, types }) Callback plus an array of strings JavaScript Types are arbitrary identifiers you define
transition.types A live Set of strings JavaScript Mutable while the transition runs; useful for late decisions
:active-view-transition CSS Matches on the root while any transition is running
:active-view-transition-type(a, b) Comma-separated type list CSS Matches if any listed type is active
view-transition-name <custom-ident> | none CSS Must be unique among captured elements at capture time
view-transition-class <custom-ident>+ CSS Styles a set of groups without sharing an identifier
view-transition-group normal | contain | nearest | <custom-ident> CSS Places this group inside another rather than at the root
::view-transition-group(*) Pseudo-element, * matches all CSS The animating container for one captured element
::view-transition-old/new(name) Pseudo-element CSS The two snapshots inside a group’s image pair

Annotated examples

Direction-aware navigation

// Intent: the router knows which way the user is going; say it once.
async function navigate(url, direction /* 'forward' | 'back' */) {
  const nextView = await loadRoute(url);
  if (!document.startViewTransition) { swapDOM(nextView); return; }

  const transition = document.startViewTransition({
    update: () => swapDOM(nextView),
    types: [direction],
  });
  await transition.finished.catch(() => {});   // skipped is not an error
}
/* Intent: one pseudo-element tree, two choreographies, chosen by type. */
html:active-view-transition-type(forward) {
  &::view-transition-old(page) { animation: slide-out-left 220ms ease-in both; }
  &::view-transition-new(page) { animation: slide-in-right 260ms ease-out both; }
}

html:active-view-transition-type(back) {
  &::view-transition-old(page) { animation: slide-out-right 220ms ease-in both; }
  &::view-transition-new(page) { animation: slide-in-left 260ms ease-out both; }
}

@keyframes slide-out-left  { to   { transform: translateX(-24px); opacity: 0; } }
@keyframes slide-in-right  { from { transform: translateX(24px);  opacity: 0; } }
@keyframes slide-out-right { to   { transform: translateX(24px);  opacity: 0; } }
@keyframes slide-in-left   { from { transform: translateX(-24px); opacity: 0; } }

/* Accessibility gate: keep the crossfade, drop the directional travel. */
@media (prefers-reduced-motion: reduce) {
  html:active-view-transition-type(forward),
  html:active-view-transition-type(back) {
    &::view-transition-old(page),
    &::view-transition-new(page) { animation-name: fade; animation-duration: 120ms; }
  }
  @keyframes fade { from { opacity: 0; } }
}

Rendering Impact: transform + opacity on the snapshot pseudo-elements — composite only. The type selector is matched once during style; it adds no per-frame cost.

A morph that holds together

/* Intent: the card becomes the detail panel, and everything inside the card
   travels with it instead of detaching. */
.card             { view-transition-name: var(--card-name); }
.card__media      { view-transition-name: var(--media-name);
                    view-transition-group: contain; }   /* nested in the card group */
.card__title      { view-transition-name: var(--title-name);
                    view-transition-group: contain; }

/* Shared styling for every nested child, without naming each one. */
.card__media, .card__title { view-transition-class: card-part; }

::view-transition-group(.card-part) {
  animation-duration: 300ms;
  animation-timing-function: cubic-bezier(0.2, 0, 0, 1);
}

@media (prefers-reduced-motion: reduce) {
  ::view-transition-group(*) { animation-duration: 1ms; }
}

Rendering Impact: composite. Nesting changes the tree, not the frame cost: the child group inherits the parent’s transform rather than computing an independent path.

Deciding the type after the fetch

// Intent: we only know it is a "filter" change, not a navigation, once the
// response tells us the result set is the same collection.
const transition = document.startViewTransition({ update: applyResults });
const results = await fetchResults();
transition.types.add(results.sameCollection ? 'filter' : 'forward');

Rendering Impact: none per frame. types is a live set; adding to it before the animation starts changes which rules match without restarting anything.

DevTools workflow

  1. Trigger the transition with the Animations panel recording and playback slowed to 10%. Each group appears as its own animation row, so a nested child shows up under its parent’s timing rather than with an independent duration.
  2. While paused, inspect ::view-transition in Elements. The tree structure is literal: nested groups are nested nodes. If a child you expected to be nested sits at the root, view-transition-group did not apply — usually because the child was not named.
  3. Force a type in the Styles pane by toggling the :active-view-transition-type state on the root, exactly as you would toggle :hover. This lets you iterate on the backward-navigation choreography without navigating backward.
  4. Check the Performance trace for a single compositor block covering the whole transition. Two separate blocks with a gap usually mean a snapshot was invalidated mid-transition by a layout change inside the callback.

Failure modes and fixes

Every route change looks the same despite types being passed. The most common cause is matching the type on the wrong element. :active-view-transition-type() matches on the document root, so the rule must be html:active-view-transition-type(x) (or :root:...), not a selector on the pseudo-element alone.

The morph “shatters” — the image flies separately from its card. The child is a sibling group rather than a nested one. Add view-transition-group: contain to the child, and confirm in the Elements tree that the nesting took effect.

A nested child is clipped when it should overflow. That is nesting working as designed: the parent group clips its subtree. If the child genuinely needs to escape the container, it must be a root-level group, and the container’s shape change has to be choreographed to match.

The transition aborts with a duplicate-name error. Two elements carried the same view-transition-name at capture time — usually a list where every item is named statically. Derive the name from the item identifier, or assign names only to the two elements participating in this specific morph, as described in implementing cross-document view transitions in SPAs.

Types leak between transitions. They cannot — the flag is cleared when the transition ends. What can leak is a view-transition-name left attached, which then participates in the next transition. Clear names when finished settles.

Adding types to an existing transitionFour changes, none of which touch the DOM update itself.Adding types to an existing transition1Give the router one place that knows the direction or kind of change.A single string, not a branch2Pass it as types on startViewTransition and keep the callback unchanged.Existing behaviour still works untyped3Move the current animation rules underhtml:active-view-transition-type(forward).Forward navigation looks exactly as before4Add the mirrored rules for the other types, then a reduced-motion overridefor all of them.One gate covers every direction
Four changes, none of which touch the DOM update itself.

Accessibility and reduced-motion notes

Directional motion is the part of a view transition most likely to cause discomfort, because a full-page slide moves the largest possible area of the visual field. The reduced-motion branch for a typed transition should collapse every direction to the same short crossfade rather than shortening each slide — a fast slide is still a slide, and the vestibular thresholds are about travel and area, not duration alone.

Types make that gate easier to write correctly, because all the directional rules are grouped under selectors you can override in one block. Put the reduced-motion override after the typed rules and target ::view-transition-group(*) so it also covers types added later by someone else.

One more consideration specific to nesting: a nested group inherits its parent’s transform, which means a child’s text can be scaled mid-transition. Scaled text is briefly unreadable and, at large scale factors, can be uncomfortable. Where a container changes size substantially, cross-fade the text rather than carrying it — name the text element but let its group animate opacity only.

Frequently asked questions

Are transition types just class names on the root element?

They behave similarly but are scoped to the transition rather than the document. The browser sets them when the transition starts and clears them when it finishes or is skipped, so there is no state to clean up and no way for one navigation’s styling to leak into the next.

Can I add a type after the transition has already started?

Yes. transition.types is a live set, so a type can be added after an await inside the update callback — useful when the response decides what kind of change this is. Adding a type after the animation has begun will not retroactively restart it, so add it before the ready promise settles.

What is the difference between view-transition-class and a transition type?

A class groups elements so several named groups can share one block of animation rules. A type classifies the whole transition so different navigations can look different. They are frequently used together: a type selects the choreography, a class applies it to a set of elements.

When should a group be nested rather than a sibling?

Nest it when the child stays inside its container throughout the change, so it should inherit the container’s transform and clipping. Keep it a sibling when the child has to travel somewhere the container is not going, such as a thumbnail flying to a fixed header.

Do nested groups cost more to animate?

No. Nesting changes the structure of the snapshot tree, not the number of animated frames. A nested child is transformed by its parent instead of computing an independent path, which if anything is marginally less work for the compositor.