home / skills / agents-inc / skills / web-animation-view-transitions

web-animation-view-transitions skill

/src/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-transitions

Review the files below or copy the command above to add this skill to your agents.

Files (6)
SKILL.md
15.1 KB
---
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>

Overview

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.

How this skill works

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.

When to use it

  • Animating state changes inside single-page applications for smooth UI updates
  • Creating page-to-page transitions in multi-page sites without heavy JavaScript
  • Implementing shared-element (hero) effects to preserve visual continuity
  • Providing accessible animations while respecting prefers-reduced-motion
  • Applying direction-aware transitions for forward/back navigation feedback

Best practices

  • Always feature-detect (check typeof document !== 'undefined' && 'startViewTransition' in document) and provide a functional fallback
  • Respect prefers-reduced-motion — disable or shorten animations for users who opt out
  • Use named constants or CSS custom properties for all timing/easing values; avoid magic numbers
  • Ensure view-transition-name values are unique and clean them up after the transition completes
  • Limit use to cases where native snapshot-based transitions add value; use animation libraries for physics or precise timelines

Example use cases

  • Wrap route changes in an SPA with transitionTo(updateFn) that returns a promise when finished
  • Add @view-transition { navigation: auto } to both pages of an MPA to enable cross-document transitions with no JS
  • Programmatically set view-transition-name on a thumbnail before navigation and clear it after to create a hero animation
  • Use ::view-transition-old/new to implement slide, scale, or circular reveal effects with CSS variables for timing
  • Check matchMedia('(prefers-reduced-motion: reduce)') before starting transitions to provide reduced alternatives

FAQ

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.