The View Transitions API provides a browser-native mechanism to animate between DOM states — either within the same page (SPA-style) or across a full page navigation (MPA mode, enabled in Chrome 126+) — without JavaScript animation libraries or manually managing opacity/transform states. The core pattern is document.startViewTransition( callback ): the browser captures a screenshot of the current state, executes the callback that mutates the DOM (or, for cross-document transitions, performs the navigation), captures the new state, and then runs the transition animation defined entirely in CSS. The generated pseudo-elements are ::view-transition-old(name) (the fading-out snapshot) and ::view-transition-new(name) (the fading-in new content) — the default transition is a 250ms cross-fade of the full viewport. Granular per-element transitions require two steps: (1) assign view-transition-name: hero-image in CSS (or via JavaScript before the transition) to any element you want animated independently, (2) write @keyframes rules targeting ::view-transition-old(hero-image) and ::view-transition-new(hero-image) to define the per-element animation. Names must be unique per viewport at the moment the transition fires — assigning the same view-transition-name to multiple visible elements simultaneously causes those elements’ transitions to be skipped. Cross-document (MPA) View Transitions are triggered by adding @view-transition { navigation: auto; } to the stylesheet — the browser then automatically captures and transitions between navigations to same-origin pages with no JavaScript needed. For WordPress themes this is the most impactful use case: adding eight lines of CSS to style.css produces smooth cross-fade or slide transitions on every page navigation without modifying any PHP or JavaScript. Browser support as of early 2024: same-document transitions in Chrome 111+, Edge 111+, Safari 18+ (desktop); cross-document MPA transitions in Chrome 126+ only (Firefox and Safari support pending). The scroll-driven animations post covered declarative CSS animations for scroll events; View Transitions handle state-change animations triggered by navigation and DOM updates.
Problem: A WordPress portfolio theme uses AJAX to replace the main content area when a portfolio filter category is clicked, but the content swap is jarring — the new cards appear instantly. Adding a CSS animation to the container does not work because the new content is injected after the animation would have already started on the old container.
Solution: Wrap the AJAX DOM swap in document.startViewTransition() so the browser captures the before and after states and cross-fades between them, then add per-card staggered slide-in animations using individually assigned view-transition-name values.
/* ── Cross-document (MPA) View Transition — zero JavaScript required ──────── */
/* Add to style.css — transitions every same-origin page navigation in Chrome 126+ */
@view-transition {
navigation: auto;
}
/* Slow down the default cross-fade and add a subtle upward slide */
::view-transition-old(root),
::view-transition-new(root) {
animation-duration: 300ms;
animation-timing-function: cubic-bezier(.4, 0, .2, 1);
}
::view-transition-old(root) {
animation-name: vt-fade-out;
}
::view-transition-new(root) {
animation-name: vt-slide-in;
}
@keyframes vt-fade-out {
from { opacity: 1; transform: translateY(0); }
to { opacity: 0; transform: translateY(-8px); }
}
@keyframes vt-slide-in {
from { opacity: 0; transform: translateY(12px); }
to { opacity: 1; transform: translateY(0); }
}
/* ── Per-element named transition for the site header logo ───────────────── */
.site-logo {
view-transition-name: site-logo;
contain: layout; /* required alongside view-transition-name */
}
/* Header logo morphs in place (no default cross-fade, just stays) */
::view-transition-old(site-logo),
::view-transition-new(site-logo) {
animation: none;
mix-blend-mode: normal;
}
/* ── Featured image hero transition ─────────────────────────────────────── */
/* On single.php: assign a unique name via PHP inline style */
/* <?php printf( '<div class="hero" style="view-transition-name:hero-%d">', $post->ID ); ?> */
::view-transition-old(hero),
::view-transition-new(hero) {
animation-duration: 400ms;
animation-timing-function: ease-in-out;
}
// ── Same-document (SPA-style) transition for AJAX portfolio filter ─────────
document.querySelectorAll('.portfolio-filter a').forEach(link => {
link.addEventListener('click', async (e) => {
e.preventDefault();
const category = link.dataset.category;
const grid = document.querySelector('#portfolio-grid');
// Assign unique view-transition-name to each visible card BEFORE transition
// so the browser can capture them individually
grid.querySelectorAll('.portfolio-card').forEach((card, i) => {
card.style.viewTransitionName = `card-${ i }`;
});
if ( ! document.startViewTransition ) {
// Fallback: no View Transitions support — just swap content
await loadPortfolioCategory(category, grid);
return;
}
const transition = document.startViewTransition(async () => {
await loadPortfolioCategory(category, grid);
// Assign names to incoming cards INSIDE the callback
// so the browser captures the new state with names set
grid.querySelectorAll('.portfolio-card').forEach((card, i) => {
card.style.viewTransitionName = `card-${ i }`;
});
});
// Clean up names after the transition to avoid naming conflicts
// if another filter click fires before the first transition finishes
await transition.finished;
grid.querySelectorAll('.portfolio-card').forEach(card => {
card.style.viewTransitionName = '';
});
});
});
async function loadPortfolioCategory(category, grid) {
const nonce = wpThemeData.nonce; // localized via wp_localize_script
const response = await fetch(wpThemeData.ajaxUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
action: 'filter_portfolio',
category,
_wpnonce: nonce,
}),
});
const data = await response.json();
if (data.success) grid.innerHTML = data.data.html;
}
NOTE: view-transition-name values must be unique across the entire visible document at the moment the transition snapshot is taken — if two elements share the same name, neither gets an individual transition and both fall back to the root cross-fade. The most common mistake is setting view-transition-name in plain CSS on a class used by multiple elements (e.g., .portfolio-card { view-transition-name: card; }) — this must be done per-element with unique names (via JavaScript before the transition, or via PHP inline styles with unique IDs). Also, contain: layout or contain: paint is required on an element that has view-transition-name — without containment the browser cannot efficiently capture the element’s snapshot and the transition degrades to the root-level transition. Progressive enhancement is straightforward: if ( ! document.startViewTransition ) { /* fallback */ } — browsers without support simply perform an instant DOM swap, which is the baseline experience in all existing WordPress themes anyway.