From web-view-transition
Expert guidance for implementing the View Transition API — covering same-document (SPA) transitions with `document.startViewTransition()`, cross-document (MPA) transitions with `@view-transition`, customizing animations via CSS pseudo-elements (`::view-transition-old`, `::view-transition-new`, `::view-transition-group`), per-element animations with `view-transition-name`, JavaScript control via the `ViewTransition` promises (`ready`, `finished`, `updateCallbackDone`), context-aware transition types with `:active-view-transition-type()`, and graceful fallbacks for unsupported browsers. Use this skill when someone wants page transition animations, shared-element transitions, slide/fade/circular-reveal effects, or asks about `startViewTransition`, `@view-transition`, `view-transition-name`, `::view-transition-*` pseudo-elements, or the `ViewTransition` object — even if they just say "smooth page transitions" or "animate between routes".
How this skill is triggered — by the user, by Claude, or both
Slash command
/web-view-transition:web-view-transitionThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
The View Transition API creates animated transitions between DOM states or page
The View Transition API creates animated transitions between DOM states or page navigations with minimal code. The browser handles snapshotting old/new states and animating between them.
Browser support: Chrome 111+, Edge 111+, Firefox 144+, Safari 18+ (Baseline 2025). Always provide a no-animation fallback.
When a transition fires, the browser:
The pseudo-element tree looks like:
::view-transition
└─ ::view-transition-group(root)
└─ ::view-transition-image-pair(root)
├─ ::view-transition-old(root) ← static old snapshot
└─ ::view-transition-new(root) ← live new snapshot
Named elements get their own group alongside root.
Wrap your DOM update in document.startViewTransition(). The default
cross-fade animation applies automatically.
function navigate(updateFn) {
// Graceful fallback — the update still runs, just without animation
if (!document.startViewTransition) {
updateFn();
return;
}
document.startViewTransition(updateFn);
}
The callback can be async; the transition waits for the returned promise.
document.startViewTransition(async () => {
const data = await fetchNewContent();
renderContent(data);
});
By default the whole page cross-fades. Assign view-transition-name to
elements you want to animate independently. Names must be unique per frame.
/* CSS */
.hero-image {
view-transition-name: hero;
}
nav {
view-transition-name: site-nav;
}
This generates separate ::view-transition-group(hero) and
::view-transition-group(site-nav) branches. The browser automatically
morphs position and size between old/new states for those elements.
Avoid conflicts: if two rendered elements share the same name, ready
rejects and the transition is skipped. Assign names dynamically and clean them
up:
document.startViewTransition(() => {
clickedCard.style.viewTransitionName = "selected-card";
updateDOM();
// Reset in the next frame to prevent bfcache conflicts
requestAnimationFrame(() => {
clickedCard.style.viewTransitionName = "";
});
});
No JavaScript needed. Both the outgoing and destination pages must opt in with
the CSS @view-transition at-rule. Both pages must be same-origin.
/* Add to CSS on BOTH pages */
@view-transition {
navigation: auto;
}
That's it for a default cross-fade. Customize animations in the destination page's CSS.
Target the pseudo-elements to override the default cross-fade. Prefer targeting
::view-transition-group() for duration/easing so the values cascade to
old/new consistently.
/* Slow down all transitions */
::view-transition-group(root) {
animation-duration: 0.4s;
animation-timing-function: ease-in-out;
}
/* Custom swipe-up animation */
@keyframes slide-out {
to {
transform: translateY(-100%);
}
}
@keyframes slide-in {
from {
transform: translateY(100%);
}
}
::view-transition-old(root) {
animation: 0.4s ease-in both slide-out;
}
::view-transition-new(root) {
animation: 0.4s ease-in both slide-in;
}
Use * to target all named groups at once:
::view-transition-group(*) {
animation-duration: 0.3s;
}
For programmatic animations that depend on runtime data (e.g. click position),
use the ViewTransition.ready promise together with the Web Animations API.
let lastClick;
document.addEventListener("click", (e) => (lastClick = e));
function navigate(updateFn) {
if (!document.startViewTransition) {
updateFn();
return;
}
const x = lastClick?.clientX ?? innerWidth / 2;
const y = lastClick?.clientY ?? innerHeight / 2;
const r = Math.hypot(
Math.max(x, innerWidth - x),
Math.max(y, innerHeight - y),
);
const transition = document.startViewTransition(updateFn);
transition.ready.then(() => {
document.documentElement.animate(
{
clipPath: [
`circle(0 at ${x}px ${y}px)`,
`circle(${r}px at ${x}px ${y}px)`,
],
},
{
duration: 500,
easing: "ease-in",
pseudoElement: "::view-transition-new(root)",
},
);
});
}
// Required CSS to disable default cross-fade blending
// ::view-transition-image-pair(root) { isolation: auto; }
// ::view-transition-old(root), ::view-transition-new(root) {
// animation: none; mix-blend-mode: normal; display: block;
// }
document.startViewTransition() returns a ViewTransition object. Access it
in MPA transitions via PageSwapEvent.viewTransition (outgoing page) and
PageRevealEvent.viewTransition (incoming page). Also available anywhere via
document.activeViewTransition.
| Promise | Fulfills when |
|---|---|
updateCallbackDone | The DOM update callback resolved |
ready | Pseudo-elements created, animation about to start |
finished | Animation completed, new view is interactive |
const transition = document.startViewTransition(updateFn);
// Know when the DOM is updated (regardless of animation outcome)
transition.updateCallbackDone.then(() => console.log("DOM updated"));
// Run custom JS animation at the right moment
transition.ready.then(() => {
/* animate */
});
// Cleanup after animation completes
transition.finished.then(() => {
element.style.viewTransitionName = "";
});
// Skip animation (DOM update still runs)
transition.skipTransition();
async function handleNav(isBack) {
if (isBack) document.documentElement.classList.add("back-nav");
const transition = document.startViewTransition(updateFn);
try {
await transition.finished;
} finally {
document.documentElement.classList.remove("back-nav");
}
}
.back-nav::view-transition-old(root) {
animation-name: slide-out-right;
}
.back-nav::view-transition-new(root) {
animation-name: slide-in-left;
}
Types let you apply different animations to the same elements depending on context (e.g. "forwards" vs "backwards" in a gallery).
startViewTransitiondocument.startViewTransition({
update() {
renderNextImage();
},
types: ["forwards"],
});
Modify types dynamically on the returned object:
const vt = document.startViewTransition({ update: renderFn });
if (isBack) vt.types.add("backwards");
@view-transition@view-transition {
navigation: auto;
types: chapter-forward;
}
Or assign dynamically via pageswap/pagereveal events:
window.addEventListener("pageswap", (e) => {
if (e.viewTransition && goingForward(e)) {
e.viewTransition.types.add("forwards");
}
});
/* Styles when any transition is active */
html:active-view-transition {
:root {
view-transition-name: none;
}
.card {
view-transition-name: card;
}
}
/* Different animations per type */
html:active-view-transition-type(forwards) {
&::view-transition-old(card) {
animation-name: slide-out-left;
}
&::view-transition-new(card) {
animation-name: slide-in-right;
}
}
html:active-view-transition-type(backwards) {
&::view-transition-old(card) {
animation-name: slide-out-right;
}
&::view-transition-new(card) {
animation-name: slide-in-left;
}
}
Use these events to set view-transition-name dynamically on MPA pages,
enabling shared-element transitions between specific elements.
// outgoing page — runs just before unload
window.addEventListener("pageswap", async (e) => {
if (!e.viewTransition) return;
const targetUrl = new URL(e.activation.entry.url);
if (isDetailPage(targetUrl)) {
const id = extractId(targetUrl);
document.querySelector(`#item-${id} img`).style.viewTransitionName =
"hero-img";
// Clean up to avoid bfcache naming conflicts
await e.viewTransition.finished;
document.querySelector(`#item-${id} img`).style.viewTransitionName = "";
}
});
// incoming page — runs when new page first renders
window.addEventListener("pagereveal", async (e) => {
if (!e.viewTransition) return;
const fromUrl = navigation.activation.from?.url;
if (fromUrl && isListPage(new URL(fromUrl))) {
document.querySelector(".detail-hero").style.viewTransitionName =
"hero-img";
await e.viewTransition.finished;
document.querySelector(".detail-hero").style.viewTransitionName = "";
}
});
Duplicate view-transition-name: if two visible elements share a name,
ViewTransition.ready rejects and the transition is skipped silently. Clean up
names after transitions using finished.
bfcache conflicts: when the back button is pressed, the page is restored
from cache. If names were left set, the next pagereveal handler sets them
again → duplicate → skipped. Always remove names after finished.
Hidden page skips transition: if the page is not visible (minimized, other
tab), startViewTransition skips the animation automatically. This is correct
behavior — the DOM update still runs.
MPA cross-origin: cross-document transitions only work between same-origin
pages. A cross-origin redirect in the chain also disables the activation
property (PageSwapEvent.activation returns null).
match-element keyword: you can use view-transition-name: match-element
to automatically assign unique names to all matched elements, useful for list
items without manual ID assignment.
Respect the user's motion preference. When prefers-reduced-motion: reduce is
set, either remove the animation or make it instant.
@media (prefers-reduced-motion: reduce) {
::view-transition-group(*),
::view-transition-old(*),
::view-transition-new(*) {
animation-duration: 0.01ms !important;
}
}
Read these when you need deeper detail than what is covered above.
references/api.md — Complete API reference: browser support table,
Document.startViewTransition() signature, all ViewTransition promises,
ViewTransitionTypeSet methods, pageswap/pagereveal event objects, and
every CSS addition (@view-transition, view-transition-name,
view-transition-class, pseudo-classes, pseudo-elements, <link rel="expect">)references/using.md — Detailed walkthrough: how the transition process
works step-by-step, SPA and MPA setup, CSS animation customization, per-element
naming, the circular-reveal Web Animations API pattern, back/forward direction
patterns, pageswap/pagereveal shared-element examples, and MPA render
stabilization with <link rel="expect">references/using-types.md — In-depth guide to transition types: SPA
types option, ViewTransition.types.add(), type-specific CSS with
:active-view-transition-type(), static MPA types in @view-transition,
and dynamic MPA types via pageswap/pagereveal with direction detectionCreates, edits, and optimizes skills for Claude Code, including drafting, evaluating with test prompts, iterating on performance, and improving skill descriptions for better triggering accuracy.
npx claudepluginhub 46ki75/claude-plugins --plugin web-view-transition