Interface updates often feel abrupt even when they are fast. A filtered list jumps into a new arrangement, a thumbnail suddenly becomes a large detail image, or an application swaps panels without showing users how the old state relates to the new one.

The View Transition API lets the browser capture the old and new visual states and animate between them. As of September 2026, same-document document.startViewTransition() is a Baseline 2025 feature across current browser versions, but older browsers still exist. The right implementation treats animation as progressive enhancement: the update must work perfectly without the API.

This tutorial builds an accessible same-document product-grid transition with plain HTML, CSS, and JavaScript. The same pattern can sit inside React, Vue, Nuxt, or another client-side framework as long as the state update occurs inside the transition callback.

Build the UI Without Animation First

Start with semantic controls and a list that works independently of visual effects:

<nav class="filters" aria-label="Filter products">
  <button type="button" data-filter="all" aria-pressed="true">All</button>
  <button type="button" data-filter="hardware" aria-pressed="false">Hardware</button>
  <button type="button" data-filter="software" aria-pressed="false">Software</button>
</nav>

<p id="result-status" class="sr-only" aria-live="polite"></p>

<ul id="product-grid" class="product-grid">
  <li class="product-card" data-id="keyboard" data-category="hardware">
    <img src="keyboard.webp" alt="Compact mechanical keyboard">
    <h2>Compact Keyboard</h2>
  </li>
  <li class="product-card" data-id="editor" data-category="software">
    <img src="editor.webp" alt="Code editor interface">
    <h2>Code Editor</h2>
  </li>
</ul>

Use a real list because the cards are a collection, and use buttons because filters perform actions. aria-pressed communicates the selected filter to assistive technology; the live region reports the resulting item count.

Add a visually hidden utility:

.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}

Now implement the state change with no animation dependency:

const grid = document.querySelector('#product-grid');
const status = document.querySelector('#result-status');
const buttons = [...document.querySelectorAll('[data-filter]')];

function applyFilter(filter) {
  let visibleCount = 0;

  for (const card of grid.children) {
    const visible = filter === 'all' || card.dataset.category === filter;
    card.hidden = !visible;
    if (visible) visibleCount += 1;
  }

  for (const button of buttons) {
    button.setAttribute(
      'aria-pressed',
      String(button.dataset.filter === filter),
    );
  }

  status.textContent = `${visibleCount} products shown`;
}

Test keyboard input, focus visibility, filtering, and announcements now. Animation should enhance a correct interaction, not hide an incomplete one.

Wrap the DOM Update in a View Transition

Create one helper that respects feature support and the user's motion preference:

const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)');

function updateWithTransition(update) {
  if (!document.startViewTransition || reduceMotion.matches) {
    update();
    return null;
  }

  return document.startViewTransition(update);
}

for (const button of buttons) {
  button.addEventListener('click', () => {
    updateWithTransition(() => applyFilter(button.dataset.filter));
  });
}

The browser captures the old state, runs the callback, captures the new state, and creates temporary pseudo-elements for animation. Without custom CSS, the document receives a default cross-fade.

Do not make the callback wait for slow network requests. Fetch data first, then pass the synchronous DOM commit into startViewTransition. Holding rendering while unrelated work completes can make the interface feel frozen.

async function loadCategory(filter) {
  const response = await fetch(`/api/products?category=${encodeURIComponent(filter)}`);
  if (!response.ok) throw new Error(`Request failed: ${response.status}`);

  const products = await response.json();

  updateWithTransition(() => {
    renderProducts(products);
    updateFilterState(filter, products.length);
  });
}

Always show a real loading and error state outside the transition helper.

Animate Individual Cards with Stable Names

The root cross-fade is useful, but named elements make reordering feel spatial. Assign a unique view-transition-name to each visible card:

function prepareTransitionNames() {
  for (const card of grid.children) {
    card.style.viewTransitionName = `product-${card.dataset.id}`;
  }
}

prepareTransitionNames();

Names must be unique among participating elements. Use stable application identifiers, not array indexes, because indexes change when the list is sorted or filtered. If identifiers can contain spaces or punctuation, escape or map them to safe CSS identifiers rather than interpolating raw user input.

Modern implementations also support automatic matching through match-element in relevant contexts, but explicit stable names remain easier to reason about when you need precise card-to-card continuity.

Customize the snapshots with View Transition pseudo-elements:

::view-transition-group(*) {
  animation-duration: 260ms;
  animation-timing-function: cubic-bezier(.2, .8, .2, 1);
}

::view-transition-old(root) {
  animation: 160ms ease-out both fade-out;
}

::view-transition-new(root) {
  animation: 220ms ease-in both fade-in;
}

@keyframes fade-out {
  to { opacity: 0; }
}

@keyframes fade-in {
  from { opacity: 0; }
}

Keep durations short for routine UI actions. A transition should explain continuity, not delay the next task.

Create a Shared-Element Detail Transition

Suppose selecting a card opens an in-page detail panel. Give the thumbnail and destination image the same transition name, but never at the same time.

function openProduct(card) {
  const id = card.dataset.id;
  const thumbnail = card.querySelector('img');

  thumbnail.style.viewTransitionName = `hero-${id}`;

  const transition = updateWithTransition(() => {
    renderProductDetail(id);

    const hero = document.querySelector('#product-detail img');
    hero.style.viewTransitionName = `hero-${id}`;
  });

  transition?.finished.finally(() => {
    thumbnail.style.viewTransitionName = '';
    const hero = document.querySelector('#product-detail img');
    if (hero) hero.style.viewTransitionName = '';
  });
}

The old thumbnail disappears as the detail image appears, so the browser can pair their snapshots. Clearing temporary names after finished reduces accidental collisions in later transitions.

Do not move keyboard focus merely for visual continuity. Move it only when the interaction semantics require it—for example, into a true modal dialog following the WAI-ARIA dialog pattern. If the detail replaces the page's main view, place focus on an appropriate heading or provide a predictable back control.

Handle Reduced Motion in Both JavaScript and CSS

The JavaScript helper skips transitions when the user requests reduced motion. Add a CSS safeguard too:

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

Reduced motion does not always mean zero animation, but skipping decorative spatial movement is a reliable default. Preserve the state change and feedback; remove only the motion layer.

Also avoid transitions that zoom the whole viewport, simulate rapid camera travel, flash high-contrast frames, or run unexpectedly. These effects can be uncomfortable even at short durations.

Prevent Duplicate and Interrupted Transitions

Fast repeated clicks can start a new update while another animation is active. Disable controls briefly only if the underlying operation cannot safely overlap. For simple filters, let the newest action win and treat the transition as disposable.

let activeTransition;

function updateLatest(update) {
  activeTransition?.skipTransition();
  activeTransition = updateWithTransition(update);

  activeTransition?.finished.finally(() => {
    activeTransition = null;
  });
}

skipTransition() skips the animation but still runs the DOM-update callback. That makes it suitable for clearing an obsolete visual effect without losing state.

Do not assume finished always means the business action succeeded. It reports the visual transition lifecycle, not your API request, database write, or router result.

Framework Integration

Frameworks may batch rendering beyond the synchronous callback. The rule is simple: the callback's promise should resolve only after the new DOM is committed.

In Vue, update reactive state and wait for nextTick:

import { nextTick } from 'vue';

function selectCategory(category) {
  return updateWithTransition(async () => {
    selectedCategory.value = category;
    await nextTick();
  });
}

In React, use the integration recommended by the React version in your project rather than forcing arbitrary timers. Verify the DOM commit in browser tests because rendering behavior can vary by framework mode.

Keep the feature-detection wrapper at the boundary. That prevents components from scattering browser checks and reduced-motion logic throughout the codebase.

Troubleshooting

Nothing animates

Check support in the actual browser and confirm the update changes rendered pixels:

console.log('startViewTransition' in document);
console.log(window.matchMedia('(prefers-reduced-motion: reduce)').matches);

DevTools may emulate reduced motion. Also confirm no code immediately replaces the transitioned DOM a second time.

A named element causes InvalidStateError

Two rendered elements probably share the same view-transition-name. Inspect computed styles and generate names from unique, stable IDs. Hidden elements do not participate, but duplicated visible names are invalid.

The old image stretches or crops badly

Old and new snapshots may have different aspect ratios. Keep object-fit consistent and customize the corresponding ::view-transition-old(name) and ::view-transition-new(name) pseudo-elements. Avoid transitioning between semantically unrelated assets merely because they occupy similar positions.

The transition flickers after fetching

Complete network work and image decoding before starting the transition. For a critical image, call await image.decode() after assigning its source, then commit the visible state. Provide a timeout or fallback so a broken asset cannot block the interface indefinitely.

Browser tests are flaky

Tests should usually assert the final state, not animation frames. Emulate reduced motion or await document.activeViewTransition?.finished when a test specifically needs the lifecycle. Never use a fixed sleep as proof that rendering completed.

Production Checklist

  • The interface works correctly when startViewTransition is unavailable.
  • Semantic controls, keyboard access, focus behavior, and announcements work without animation.
  • Reduced-motion users receive an immediate or minimal-motion update.
  • Every explicit view-transition-name is unique and based on a stable ID.
  • Data fetching finishes before the visual transition begins.
  • Transition callbacks cover the framework's actual DOM commit.
  • Repeated input cannot leave stale state or duplicate transition names.
  • Routine animations are brief and do not block interaction.
  • Browser tests assert final UI state and include a no-support path.
  • Performance is checked on representative mobile hardware, not only a desktop development machine.

Official Sources

The strongest View Transition implementation is deliberately boring without animation: the state changes, focus remains predictable, and assistive technology receives the same information. Supporting browsers then add continuity on top, turning abrupt updates into motion that helps users understand what changed.