home / skills / agents-inc / skills / web-animation-view-transitions
This skill guides implementing native View Transitions API patterns with feature detection, accessibility fallbacks, and clean naming to deliver smooth SPA and
npx playbooks add skill agents-inc/skills --skill web-animation-view-transitionsReview the files below or copy the command above to add this skill to your agents.
---
name: web-animation-view-transitions
description: View Transitions API patterns - same-document transitions, cross-document MPA transitions, shared element animations, pseudo-element styling, accessibility
---
# View Transitions API Patterns
> **Quick Guide:** Use the View Transitions API for native page/state transitions. `document.startViewTransition()` for same-document, `@view-transition { navigation: auto }` for cross-document MPA. Always feature-detect before use and respect `prefers-reduced-motion`.
---
<critical_requirements>
## CRITICAL: Before Using This Skill
> **All code must follow project conventions in CLAUDE.md** (kebab-case, named exports, import ordering, `import type`, named constants)
**(You MUST feature-detect before using startViewTransition - it is NOT available in all browsers)**
**(You MUST respect prefers-reduced-motion by providing reduced or disabled animations)**
**(You MUST ensure view-transition-name values are unique - duplicate names break transitions)**
**(You MUST clean up dynamically assigned view-transition-name values after transitions complete)**
**(You MUST use named constants for all animation timing values - NO magic numbers)**
</critical_requirements>
---
**Auto-detection:** View Transitions API, startViewTransition, view-transition-name, @view-transition, ::view-transition, pageswap, pagereveal, ViewTransition, view-transition-class
**When to use:**
- Animating state changes in single-page applications
- Creating smooth page-to-page transitions in multi-page applications
- Implementing shared element (hero) animations between views
- Providing visual continuity during navigation
- Creating custom transition effects (slide, scale, circular reveal)
**Key patterns covered:**
- Same-document transitions with startViewTransition()
- Cross-document MPA transitions with @view-transition CSS
- view-transition-name for shared element animations
- Pseudo-element styling (::view-transition-old, ::view-transition-new)
- Direction-aware transitions with :active-view-transition-type()
- Feature detection and graceful fallbacks
- prefers-reduced-motion accessibility patterns
**When NOT to use:**
- Complex physics-based animations (use animation libraries)
- Animations requiring precise timeline control
- Browsers without View Transitions support (always provide fallback)
- Simple hover/focus effects (use CSS transitions)
**Detailed Resources:**
- For code examples, see [examples/](examples/) folder
- For decision frameworks and anti-patterns, see [reference.md](reference.md)
---
<philosophy>
## Philosophy
The View Transitions API provides a native browser mechanism for creating animated transitions between DOM states or pages. It captures "before" and "after" snapshots, overlays them as pseudo-elements, and animates between them.
**Core principles:**
1. **Native over library** - Browser-native transitions are more performant and require less JavaScript
2. **Progressive enhancement** - Always feature-detect and provide functional fallback
3. **Snapshot-based** - Old state is captured as a screenshot, new state as a live representation
4. **CSS-driven** - Customize animations through pseudo-element CSS, not JavaScript
5. **Accessibility-first** - Always respect prefers-reduced-motion user preferences
</philosophy>
---
<patterns>
## Core Patterns
### Pattern 1: Feature Detection with Fallback
Always check for API support before using View Transitions.
#### Basic Feature Detection
```typescript
const SUPPORTS_VIEW_TRANSITIONS =
typeof document !== "undefined" && "startViewTransition" in document;
function updateWithTransition(updateFn: () => void | Promise<void>): void {
if (!SUPPORTS_VIEW_TRANSITIONS) {
updateFn();
return;
}
document.startViewTransition(() => updateFn());
}
```
**Why good:** Prevents runtime errors in unsupported browsers, provides seamless fallback, named constant for reusability
```typescript
// Bad Example - No feature detection
document.startViewTransition(() => updateDOM()); // Crashes in Firefox < 144!
```
**Why bad:** Crashes in unsupported browsers, no fallback for users without support
---
### Pattern 2: Same-Document (SPA) Transitions
Animate DOM state changes within a single page using startViewTransition().
#### Basic State Transition
```typescript
const TRANSITION_DURATION_MS = 300;
type ViewTransitionCallback = () => void | Promise<void>;
async function transitionTo(updateFn: ViewTransitionCallback): Promise<void> {
if (!document.startViewTransition) {
await updateFn();
return;
}
const transition = document.startViewTransition(async () => {
await updateFn();
});
await transition.finished;
}
// Usage
function handleNavigation(page: string): void {
transitionTo(() => {
setCurrentPage(page);
});
}
```
**Why good:** Wraps feature detection, handles async updates, returns promise for chaining
#### ViewTransition Object Properties
```typescript
interface ViewTransitionPromises {
ready: Promise<void>; // Pseudo-element tree created
updateCallbackDone: Promise<void>; // DOM update complete
finished: Promise<void>; // Animation finished
}
async function transitionWithCustomAnimation(
updateFn: () => void,
): Promise<void> {
if (!document.startViewTransition) {
updateFn();
return;
}
const transition = document.startViewTransition(updateFn);
// Wait for pseudo-elements to be ready
await transition.ready;
// Now safe to apply custom animations via Web Animations API
console.log("Pseudo-elements ready for custom animation");
await transition.finished;
console.log("Transition complete");
}
```
**Why good:** Shows the three promise stages for different timing needs
---
### Pattern 3: Cross-Document (MPA) Transitions
Enable transitions between separate pages without JavaScript.
#### CSS Opt-In
```css
/* styles.css - Include on BOTH pages */
@view-transition {
navigation: auto;
}
```
**Why good:** No JavaScript required, works for traverse/push/replace navigations
```css
/* Bad Example - Obsolete meta tag syntax */
/* <meta name="view-transition" content="same-origin"> */
/* Good - Use CSS at-rule instead */
@view-transition {
navigation: auto;
}
```
**Why bad:** Meta tag syntax is obsolete, CSS at-rule is the current standard
---
### Pattern 4: Shared Element Transitions
Create hero animations by giving matching elements the same view-transition-name.
#### CSS Shared Elements
```css
/* On list page */
.product-thumbnail {
view-transition-name: product-hero;
}
/* On detail page */
.product-image {
view-transition-name: product-hero;
}
/* Customize the transition */
::view-transition-group(product-hero) {
animation-duration: 300ms;
animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
}
```
**Why good:** Same name creates automatic shared element transition, custom timing via pseudo-element
#### Dynamic Name Assignment
```typescript
function setTransitionNames(elements: Array<[HTMLElement, string]>): void {
elements.forEach(([el, name]) => {
el.style.viewTransitionName = name;
});
}
function clearTransitionNames(elements: HTMLElement[]): void {
elements.forEach((el) => {
el.style.viewTransitionName = "";
});
}
async function transitionWithSharedElement(
element: HTMLElement,
name: string,
updateFn: () => void,
): Promise<void> {
if (!document.startViewTransition) {
updateFn();
return;
}
element.style.viewTransitionName = name;
const transition = document.startViewTransition(updateFn);
await transition.finished;
// Clean up to prevent conflicts
element.style.viewTransitionName = "";
}
```
**Why good:** Dynamic assignment allows programmatic control, cleanup prevents name conflicts
---
### Pattern 5: Custom CSS Animations
Override default cross-fade with custom animations via pseudo-elements.
#### Slide Transitions
```css
/* Named constants as CSS custom properties */
:root {
--transition-duration: 300ms;
--transition-easing: ease-in-out;
}
@keyframes slide-out-left {
to {
transform: translateX(-100%);
}
}
@keyframes slide-in-right {
from {
transform: translateX(100%);
}
}
::view-transition-old(root) {
animation: slide-out-left var(--transition-duration) var(--transition-easing);
}
::view-transition-new(root) {
animation: slide-in-right var(--transition-duration) var(--transition-easing);
}
```
**Why good:** CSS custom properties for timing constants, GPU-accelerated transforms
#### Scale and Fade
```css
:root {
--scale-duration: 250ms;
--scale-hidden: 0.95;
}
@keyframes scale-down {
to {
transform: scale(var(--scale-hidden));
opacity: 0;
}
}
@keyframes scale-up {
from {
transform: scale(calc(1 / var(--scale-hidden)));
opacity: 0;
}
}
::view-transition-old(root) {
animation: scale-down var(--scale-duration) ease-in;
}
::view-transition-new(root) {
animation: scale-up var(--scale-duration) ease-out;
}
```
**Why good:** Scale and opacity are GPU-accelerated, CSS variables for consistent values
---
### Pattern 6: Direction-Aware Transitions
Use different animations for forward vs backward navigation.
#### CSS with active-view-transition-type
```css
/* Forward navigation */
html:active-view-transition-type(forwards) {
&::view-transition-old(content) {
animation-name: slide-out-left;
}
&::view-transition-new(content) {
animation-name: slide-in-right;
}
}
/* Backward navigation */
html:active-view-transition-type(backwards) {
&::view-transition-old(content) {
animation-name: slide-out-right;
}
&::view-transition-new(content) {
animation-name: slide-in-left;
}
}
@keyframes slide-out-right {
to {
transform: translateX(100%);
}
}
@keyframes slide-in-left {
from {
transform: translateX(-100%);
}
}
```
#### Setting Navigation Types
```typescript
function setNavigationType(
transition: ViewTransition,
type: "forwards" | "backwards",
): void {
if ("types" in transition) {
(transition.types as Set<string>).add(type);
}
}
// For MPA, use pagereveal event
window.addEventListener("pagereveal", (e) => {
const event = e as PageRevealEvent;
if (event.viewTransition && "activation" in navigation) {
const navActivation = navigation.activation;
if (navActivation) {
const fromUrl = navActivation.from?.url;
const toUrl = navActivation.entry?.url;
// Determine direction based on URL structure
if (fromUrl && toUrl) {
const isForward = toUrl.includes("/detail");
setNavigationType(
event.viewTransition,
isForward ? "forwards" : "backwards",
);
}
}
}
});
```
**Why good:** Different animations for different navigation directions improve UX
---
### Pattern 7: Accessibility - Reduced Motion
Always respect user preferences for reduced motion.
#### CSS Approach
```css
@media (prefers-reduced-motion: reduce) {
::view-transition-old(root),
::view-transition-new(root) {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
}
/* Or disable entirely */
::view-transition-group(*),
::view-transition-old(*),
::view-transition-new(*) {
animation: none !important;
}
}
/* Provide subtle alternative feedback */
@media (prefers-reduced-motion: reduce) {
::view-transition-old(root) {
animation: fade-out 150ms ease-out;
}
::view-transition-new(root) {
animation: fade-in 150ms ease-in;
}
}
@keyframes fade-out {
to {
opacity: 0;
}
}
@keyframes fade-in {
from {
opacity: 0;
}
}
```
**Why good:** Respects user preferences, provides subtle alternative instead of nothing
#### JavaScript Approach
```typescript
const REDUCED_MOTION_QUERY = "(prefers-reduced-motion: reduce)";
function shouldEnableTransitions(): boolean {
// Check reduced motion preference
if (window.matchMedia(REDUCED_MOTION_QUERY).matches) {
return false;
}
// Check for API support
return "startViewTransition" in document;
}
function transitionWithAccessibility(updateFn: () => void): void {
if (!shouldEnableTransitions()) {
updateFn();
return;
}
document.startViewTransition(updateFn);
}
// Hook for monitoring preference changes
function useReducedMotion(): boolean {
const query = window.matchMedia(REDUCED_MOTION_QUERY);
let prefersReduced = query.matches;
query.addEventListener("change", (e) => {
prefersReduced = e.matches;
});
return prefersReduced;
}
```
**Why good:** Checks preference before initiating transition, reactive to preference changes
---
### Pattern 8: Circular Reveal Effect
Advanced custom animation using Web Animations API.
```typescript
interface ClickPosition {
x: number;
y: number;
}
let lastClickPosition: ClickPosition = { x: 0, y: 0 };
document.addEventListener("click", (e: MouseEvent) => {
lastClickPosition = { x: e.clientX, y: e.clientY };
});
const REVEAL_DURATION_MS = 500;
const REVEAL_EASING = "ease-in-out";
async function circularRevealTransition(updateFn: () => void): Promise<void> {
if (!document.startViewTransition) {
updateFn();
return;
}
const { x, y } = lastClickPosition;
const endRadius = Math.hypot(
Math.max(x, window.innerWidth - x),
Math.max(y, window.innerHeight - y),
);
const transition = document.startViewTransition(updateFn);
await transition.ready;
document.documentElement.animate(
{
clipPath: [
`circle(0 at ${x}px ${y}px)`,
`circle(${endRadius}px at ${x}px ${y}px)`,
],
},
{
duration: REVEAL_DURATION_MS,
easing: REVEAL_EASING,
pseudoElement: "::view-transition-new(root)",
},
);
}
```
**Supporting CSS:**
```css
::view-transition-image-pair(root) {
isolation: auto;
}
::view-transition-old(root),
::view-transition-new(root) {
animation: none;
mix-blend-mode: normal;
display: block;
}
```
**Why good:** Creates engaging circular reveal from click point, uses Web Animations API for precise control
</patterns>
---
<integration>
## Integration Guide
**View Transitions is a browser-native API.** It works with any JavaScript approach and styling solution.
**Works with:**
- **Any DOM manipulation**: Updates via direct DOM, signals, or virtual DOM all work
- **Any routing solution**: Wrap route changes in startViewTransition()
- **Any styling approach**: Customize via CSS pseudo-elements
**Key integration points:**
- Call `document.startViewTransition()` before DOM updates
- Use CSS `@view-transition` for cross-document navigation
- Set `view-transition-name` via CSS or JavaScript for shared elements
</integration>
---
<critical_reminders>
## CRITICAL REMINDERS
> **All code must follow project conventions in CLAUDE.md**
**(You MUST feature-detect before using startViewTransition - it is NOT available in all browsers)**
**(You MUST respect prefers-reduced-motion by providing reduced or disabled animations)**
**(You MUST ensure view-transition-name values are unique - duplicate names break transitions)**
**(You MUST clean up dynamically assigned view-transition-name values after transitions complete)**
**(You MUST use named constants for all animation timing values - NO magic numbers)**
**Failure to follow these rules will break transitions in unsupported browsers and create inaccessible experiences.**
</critical_reminders>
This skill documents practical patterns for the View Transitions API to create native page and state transitions. It covers same-document (SPA) and cross-document (MPA) flows, shared-element animations, pseudo-element styling, direction-aware effects, and accessibility for reduced motion. The guidance emphasizes feature detection, predictable timing constants, and cleanup of dynamic names.
The skill explains how the browser captures before/after snapshots and exposes pseudo-elements you can style or animate. For SPAs you invoke document.startViewTransition() around DOM updates; for MPAs you opt in via @view-transition { navigation: auto } in CSS. It also shows how to assign view-transition-name for shared elements, use ::view-transition-old/new pseudo-elements for custom animations, and coordinate direction or circular reveal effects via the Web Animations API.
What if the browser doesn't support the View Transitions API?
Feature-detect and fall back to an immediate DOM update or a CSS transition. Never call startViewTransition unguarded.
How do I avoid conflicting shared element names?
Assign unique view-transition-name values via constants, and remove or reset them after transition.finished to prevent collisions.