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.
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.
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+opacityon 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.
typesis a live set; adding to it before the animation starts changes which rules match without restarting anything.
DevTools workflow
- 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.
- While paused, inspect
::view-transitionin 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-groupdid not apply — usually because the child was not named. - Force a type in the Styles pane by toggling the
:active-view-transition-typestate on the root, exactly as you would toggle:hover. This lets you iterate on the backward-navigation choreography without navigating backward. - 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.
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.
Related
- View Transitions API Implementation — the lifecycle, pseudo-element tree and promises that types build on
- Implementing Cross-Document View Transitions in SPAs — where the router decides what kind of navigation this is
- View Transitions vs JavaScript Page-Transition Libraries — what the declarative API can and cannot choreograph
- CSS @starting-style & Entry Effects — the mount animation that runs after the transition finishes
- Accessible Motion Architecture — the gate every directional transition needs
- Choosing View Transition Types for Route Direction — deriving direction from history and mirroring the choreography
- Morphing a List Item into a Detail View — a shared-element morph that holds together instead of shattering