UI/UX Atlas
Motion & Animation Intermediate

Enter/Exit & Shared Element Transitions (View Transitions API)

Native browser-level transitions that make page and state changes feel spatially coherent — how they work, when to use them, and how to do it right.

7 min read

The full lesson

Page navigations used to be abrupt flashes. One document replaced another with zero continuity. CSS animations could fake continuity inside a single page, but the moment you crossed a route boundary, the illusion broke.

The View Transitions API — now supported in all modern browsers — closes that gap. It gives the browser a native way to cross-fade and morph elements across navigation events. Combined with thoughtful enter/exit animation, it transforms a site from a collection of screens into a spatially coherent experience.

This lesson covers the mechanics of the View Transitions API, the design principles behind enter/exit animation, and the accessibility requirements every implementation must satisfy.


What “Enter/Exit” Actually Means

Every element that appears or disappears goes through an enter or exit transition. Done well, these small events answer two questions for the user:

  • Where did this element come from? (enter) — the spatial origin communicates hierarchy and relationship.
  • Where did it go? (exit) — a smooth removal prevents the disorienting “did I break something?” moment.

The outdated approach was a generic fade or no transition at all. Modern practice treats enter/exit as a communication tool. A modal that scales in from a trigger button signals it belongs to that button. A drawer that slides from the left signals it is navigation. Motion tokens — easing curves, durations, and scale offsets stored in your design system — keep these signals consistent across a product.

Enter patterns and their meanings

PatternSignalTypical use
Fade inNeutral appearance, no spatial originToasts, overlays without a source
Slide from directionContent coming from that spatial regionDrawers, back/forward navigation
Scale from sourceBelongs to the triggering elementModals, expand-in-place panels
Clip expandSequential reveal, ordered contentAccordions, disclosure widgets

Exit patterns

Exit should mirror enter unless you have a good reason to break that symmetry. A modal that scaled in from a button should scale back down to that button on close — the button is still in the DOM and the user’s attention returns to it. If the trigger no longer exists on the destination view, a simple fade-out is the safe fallback.


The View Transitions API: Core Mechanics

The API works in two modes: same-document (SPA-style) and cross-document (traditional multi-page navigation). Both share the same underlying snapshot-and-animate model.

How the browser executes a view transition

  1. The browser captures a screenshot (a flat raster snapshot) of the current page.
  2. Your code updates the DOM — or a navigation happens in cross-document mode.
  3. The browser captures a screenshot of the new page state.
  4. It creates two pseudo-elements — ::view-transition-old(root) and ::view-transition-new(root) — layered above everything else.
  5. It cross-fades those pseudo-elements, then removes them when the animation finishes.

The default transition is a 250 ms cross-fade of the entire viewport. That alone is enough to make any same-document update feel polished:

@layer base {
  ::view-transition-old(root),
  ::view-transition-new(root) {
    animation-duration: 200ms;
    animation-timing-function: ease-out;
  }
}

Triggering a same-document transition

Wrap any DOM mutation in document.startViewTransition():

document.startViewTransition(() => {
  // Any synchronous DOM update — React setState, innerHTML swap, classList change
  updateTheDom();
});

// Async variant (returns a ViewTransition object)
const transition = document.startViewTransition(async () => {
  await fetchAndRenderPage();
});

await transition.finished; // Promise resolves when animation completes

Cross-document transitions (MPA)

For traditional multi-page apps, opt in with a single CSS declaration. No JavaScript required:

@view-transition {
  navigation: auto;
}

Add this rule to both the outgoing and incoming pages. The browser then automatically captures and cross-fades on every same-origin navigation. This is a big win for content sites and server-rendered apps that previously had no viable path to navigation transitions.


Shared Element Transitions

The real power of the API is designating specific elements as “shared” across a transition. The browser independently animates those elements from their old position and size to their new position and size, while the rest of the page cross-fades.

Naming elements with view-transition-name

/* On the source page / state */
.product-card--active {
  view-transition-name: product-hero;
}

/* On the destination page / state */
.product-hero-image {
  view-transition-name: product-hero;
}

Any element with a view-transition-name gets its own pair of pseudo-elements: ::view-transition-old(product-hero) and ::view-transition-new(product-hero). The browser automatically interpolates position, size, and opacity between them using a position: fixed overlay. No JavaScript math, no getBoundingClientRect calls, no FLIP gymnastics.

The critical uniqueness constraint

Every view-transition-name value must be unique in the DOM at capture time. If two elements share the same name, the browser silently skips the shared transition and falls back to the root cross-fade. This is the most common cause of “why isn’t my shared transition working?” bugs.

For dynamic lists (cards, search results), assign names programmatically:

/* In a CSS-in-JS context or inline style */
.card:nth-child(1) { view-transition-name: card-1; }
.card:nth-child(2) { view-transition-name: card-2; }

You can also use @starting-style and counter techniques in modern CSS — but for large lists, JavaScript assignment is more maintainable.

Customizing the shared element animation

The auto-interpolated morph is a good default, but you can override it:

::view-transition-group(product-hero) {
  animation-duration: 400ms;
  animation-timing-function: cubic-bezier(0.2, 0, 0, 1); /* Material Design's emphasized easing */
}

::view-transition-image-pair(product-hero) {
  isolation: isolate; /* prevents blend-mode leakage from parent */
}

@starting-style: Animating Elements Into the DOM

Before @starting-style (baseline 2024), animating an element from invisible to visible on insertion required a JavaScript tick delay or a requestAnimationFrame hack. This rule solves that cleanly:

.dialog {
  opacity: 1;
  translate: 0 0;
  transition: opacity 200ms ease-out, translate 200ms ease-out;
}

@starting-style {
  .dialog {
    opacity: 0;
    translate: 0 16px;
  }
}

When the browser inserts .dialog into the DOM, it reads @starting-style as the transition’s from-state and animates to the regular declared values. No JavaScript. No setTimeout. This pairs naturally with display: none to display: block transitions, now possible with transition-behavior: allow-discrete, making enter animations purely declarative.


Design Principles for Enter/Exit and Shared Transitions

Duration and easing

Route-level transitions should feel faster than they actually are. Aim for 200–350 ms with an expressive deceleration curve (ease-out or a spring-like custom bezier). The eye notices long transitions more than short ones — err on the shorter side.

Shared element morphs can afford slightly longer durations (300–450 ms) because the user’s eye is actively tracking the element and the motion itself carries information.

Store these values as motion tokens so they stay consistent:

:root {
  --motion-enter-duration: 220ms;
  --motion-exit-duration: 160ms;
  --motion-shared-duration: 380ms;
  --motion-easing-enter: cubic-bezier(0.0, 0.0, 0.2, 1);
  --motion-easing-exit: cubic-bezier(0.4, 0.0, 1, 1);
  --motion-easing-shared: cubic-bezier(0.2, 0.0, 0, 1);
}

Exit transitions should be shorter than enter transitions. The element is leaving focus — making the user wait for it to finish departing adds friction.

Spatial consistency

The direction of a slide should match the spatial model of your information architecture. Forward navigation slides in one direction; back navigation reverses it. A bottom sheet enters from the bottom and exits to the bottom. Breaking these expectations — even subtly — produces cognitive friction that users can feel but often cannot name.

Avoid animating layout-affecting properties

Animate transform and opacity exclusively. The View Transitions API handles this correctly by default, since it renders into fixed-positioned overlays that do not affect layout. Resist the temptation to animate width, height, top, left, margin, or padding. These trigger layout recalculation on every frame, causing jank that defeats the purpose of using the API.

Do

Use transform (translate, scale) and opacity for all entering and exiting elements. Apply view-transition-name to elements that have meaningful spatial continuity between states. Keep durations short for exits (160-200ms) and slightly longer for shared morphs (300-400ms). Store all durations and easings as motion tokens.

Don't

Animate width, height, top, left, or margin — these cause layout thrash on every frame. Add view-transition-name to every element indiscriminately; reserve it for elements that truly persist across states. Use the same duration for enter and exit — exits should be faster. Apply decorative transitions to non-meaningful state changes like hover or focus.


Accessibility: prefers-reduced-motion Is Non-Negotiable

The View Transitions API does not automatically respect prefers-reduced-motion. You own this entirely. WCAG 2.2 Success Criterion 2.3.3 (Animation from Interactions, AAA) is aspirational, but 2.2.2 (Pause, Stop, Hide) and the real harm caused by vestibular disorders make reduced-motion support a baseline requirement — not a bonus.

The recommended pattern:

/* Full motion experience */
@view-transition {
  navigation: auto;
}

::view-transition-old(root),
::view-transition-new(root) {
  animation-duration: var(--motion-enter-duration);
  animation-timing-function: var(--motion-easing-enter);
}

/* Reduce to a simple crossfade or no transition */
@media (prefers-reduced-motion: reduce) {
  ::view-transition-group(*),
  ::view-transition-image-pair(*),
  ::view-transition-old(*),
  ::view-transition-new(*) {
    animation-duration: 0.001ms !important;
    animation-delay: 0ms !important;
  }
}

Setting duration to 0.001ms rather than 0ms preserves the transition lifecycle. The finished promise still resolves and JavaScript callbacks still fire, but the visual change is instantaneous. Skipping the transition entirely with a document.startViewTransition guard condition is equally valid and sometimes better for complex orchestrations.


Progressive Enhancement and Browser Support

The View Transitions API (Level 1, same-document) is baseline across Chrome, Edge, Safari 18+, and Firefox 131+. Cross-document transitions (Level 2) are baseline in Chrome 126+ and Safari 18.2+ — Firefox support is in progress as of mid-2026.

The API degrades gracefully. If document.startViewTransition does not exist, you call the DOM update directly. Feature-detect before calling:

function updateWithTransition(callback) {
  if (!document.startViewTransition) {
    callback();
    return;
  }
  document.startViewTransition(callback);
}

For cross-document transitions, the @view-transition rule is simply ignored by browsers that do not support it — pages navigate normally. No polyfill is needed. Treat it as a progressive enhancement layer.


Practical Checklist Before Shipping

A quick review before marking a transition implementation as done:

  • Every view-transition-name is unique in the DOM at the moment of transition capture.
  • prefers-reduced-motion: reduce either eliminates positional animation or collapses duration to near-zero.
  • Exit transitions are shorter than enter transitions.
  • Shared element morphs animate only compositor-safe properties (transform, opacity) — no width/height interpolation.
  • JavaScript falls back gracefully when document.startViewTransition is unavailable.
  • The finished promise (or equivalent) is awaited before running any logic that depends on the post-transition DOM state.
  • Transition durations and easings are sourced from motion design tokens, not hardcoded inline.