Search

PythonReactSEOPDF
GitHub Skill

view-transitions

Creates smooth animated transitions between views using the native View Transitions API. Use when building page transitions, state changes, or morphing elements between different DOM states in SPAs and MPAs.

mgd34msu☆ 3 Repository stars0 Repository forks

Installation

Follow the repository instructions for this skill and your agent. Review the files and dependencies before adding it to your workflow.

View source ↗Guides

Skill instructions

View Transitions API

Native browser API for smooth animated transitions between different views. Works in SPAs (JavaScript-triggered) and MPAs (navigation-based).

Browser Support

  • Same-document (SPA): Chrome 111+, Edge 111+, Safari 18+, Firefox 144+
  • Cross-document (MPA): Chrome 126+, Edge 126+, Safari 18.2+

Quick Start - SPA

// Check for support
if (!document.startViewTransition) {
  updateDOM();
  return;
}

// Trigger transition
document.startViewTransition(() => {
  updateDOM();
});

The API:

  1. Captures snapshots of current state
  2. Runs your callback to update DOM
  3. Captures snapshots of new state
  4. Animates between old and new states

Quick Start - MPA

No JavaScript needed. Opt in with CSS on both pages:

@view-transition {
  navigation: auto;
}

Transitions happen automatically on same-origin navigations.

Basic CSS Customization

/* Customize the default cross-fade */
::view-transition-old(root),
::view-transition-new(root) {
  animation-duration: 0.5s;
}

/* Fade out old view */
::view-transition-old(root) {
  animation: fade-out 0.3s ease-out both;
}

/* Fade in new view */
::view-transition-new(root) {
  animation: fade-in 0.3s ease-in both;
}

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

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

Named Transitions

Give specific elements their own transition behavior.

/* Assign a name to an element */
.header {
  view-transition-name: header;
}

.main-image {
  view-transition-name: main-image;
}

/* Names must be unique! */
/* Style transitions for named elements */
::view-transition-old(main-image),
::view-transition-new(main-image) {
  animation-duration: 0.4s;
}

/* Keep header fixed during transition */
::view-transition-group(header) {
  animation: none;
}

Pseudo-Element Tree

When a view transition runs, this pseudo-element tree is created:

::view-transition
└─ ::view-transition-group(name)
   └─ ::view-transition-image-pair(name)
      ├─ ::view-transition-old(name)
      └─ ::view-transition-new(name)
Pseudo Purpose
::view-transition Overlay containing all transitions
::view-transition-group(name) Container for size/position animation
::view-transition-image-pair(name) Contains old and new snapshots
::view-transition-old(name) Screenshot of old state
::view-transition-new(name) Live representation of new state

Transition Types

Categorize transitions for different styling:

document.startViewTransition({
  update: () => updateDOM(),
  types: ['slide-left']  // Add type identifiers
});
/* Style based on transition type */
html:active-view-transition-type(slide-left) {
  &::view-transition-old(root) {
    animation: slide-out-left 0.3s ease;
  }
  &::view-transition-new(root) {
    animation: slide-in-right 0.3s ease;
  }
}

@keyframes slide-out-left {
  to { transform: translateX(-100%); }
}

@keyframes slide-in-right {
  from { transform: translateX(100%); }
}

JavaScript API

ViewTransition Object
const transition = document.startViewTransition(() => {
  // Update DOM here
});

// Promises for timing
transition.ready.then(() => {
  // Pseudo-elements created, animation about to start
});

transition.updateCallbackDone.then(() => {
  // DOM update callback finished
});

transition.finished.then(() => {
  // Animation complete
});

// Skip the animation
transition.skipTransition();
Custom JavaScript Animation
const transition = document.startViewTransition(() => updateDOM());

transition.ready.then(() => {
  // Use Web Animations API on pseudo-elements
  document.documentElement.animate(
    [
      { clipPath: 'circle(0% at 50% 50%)' },
      { clipPath: 'circle(100% at 50% 50%)' }
    ],
    {
      duration: 500,
      easing: 'ease-out',
      pseudoElement: '::view-transition-new(root)'
    }
  );
});

React Integration

Basic Hook
function useViewTransition() {
  const startTransition = (callback) => {
    if (!document.startViewTransition) {
      callback();
      return;
    }
    document.startViewTransition(callback);
  };

  return { startTransition };
}

// Usage
function Component() {
  const { startTransition } = useViewTransition();
  const [page, setPage] = useState('home');

  const navigate = (newPage) => {
    startTransition(() => setPage(newPage));
  };

  return (
    <div>
      <nav>
        <button onClick={() => navigate('home')}>Home</button>
        <button onClick={() => navigate('about')}>About</button>
      </nav>
      {page === 'home' && <HomePage />}
      {page === 'about' && <AboutPage />}
    </div>
  );
}
With React Router
import { useNavigate } from 'react-router-dom';

function NavLink({ to, children }) {
  const navigate = useNavigate();

  const handleClick = (e) => {
    e.preventDefault();

    if (!document.startViewTransition) {
      navigate(to);
      return;
    }

    document.startViewTransition(() => {
      navigate(to);
    });
  };

  return <a href={to} onClick={handleClick}>{children}</a>;
}
React 19+ (Experimental)
import { unstable_ViewTransition as ViewTransition } from 'react';

function App() {
  const [page, setPage] = useState('home');

  return (
    <ViewTransition>
      {page === 'home' ? <Home /> : <About />}
    </ViewTransition>
  );
}

Common Patterns

Hero Image Morph
/* List page */
.thumbnail {
  view-transition-name: hero-image;
}

/* Detail page */
.hero-image {
  view-transition-name: hero-image;
}

/* The image will smoothly morph between pages */
::view-transition-group(hero-image) {
  animation-duration: 0.4s;
}
Shared Header
.header {
  view-transition-name: header;
}

/* Keep header in place */
::view-transition-old(header),
::view-transition-new(header) {
  animation: none;
  mix-blend-mode: normal;
}
Slide Transitions
@keyframes slide-from-right {
  from { transform: translateX(100%); }
}

@keyframes slide-to-left {
  to { transform: translateX(-100%); }
}

/* Forward navigation */
html:active-view-transition-type(forward) {
  &::view-transition-old(root) {
    animation: slide-to-left 0.3s ease forwards;
  }
  &::view-transition-new(root) {
    animation: slide-from-right 0.3s ease forwards;
  }
}

/* Back navigation */
html:active-view-transition-type(back) {
  &::view-transition-old(root) {
    animation: slide-from-right 0.3s ease reverse forwards;
  }
  &::view-transition-new(root) {
    animation: slide-to-left 0.3s ease reverse forwards;
  }
}
Circle Reveal
function circleReveal(x, y) {
  const transition = document.startViewTransition(() => updateContent());

  transition.ready.then(() => {
    const maxRadius = Math.hypot(
      Math.max(x, window.innerWidth - x),
      Math.max(y, window.innerHeight - y)
    );

    document.documentElement.animate(
      {
        clipPath: [
          `circle(0px at ${x}px ${y}px)`,
          `circle(${maxRadius}px at ${x}px ${y}px)`
        ]
      },
      {
        duration: 500,
        easing: 'ease-in-out',
        pseudoElement: '::view-transition-new(root)'
      }
    );
  });
}

// Trigger from click position
button.addEventListener('click', (e) => {
  circleReveal(e.clientX, e.clientY);
});
Grid Item Expansion
/* In grid */
.card {
  view-transition-name: var(--card-id);
}

/* When expanded */
.card-detail {
  view-transition-name: var(--card-id);
}
function Card({ id, onClick }) {
  return (
    <div
      className="card"
      style={{ '--card-id': `card-${id}` }}
      onClick={onClick}
    />
  );
}

MPA Cross-Document Transitions

Opt In
/* Add to both pages */
@view-transition {
  navigation: auto;
}
Customize by Page
<!-- page1.html -->
<html class="page-home">

<!-- page2.html -->
<html class="page-about">
/* Styles apply based on destination */
.page-about::view-transition-old(root) {
  animation: slide-out-left 0.3s;
}
Match Elements Across Pages

Use the new match-element value (Chrome 126+):

/* Automatically generate view-transition-name based on element identity */
.product-card {
  view-transition-name: match-element;
}

Or manually assign matching names:

/* page1.html */
.product-thumbnail[data-id="123"] {
  view-transition-name: product-123;
}

/* page2.html */
.product-hero[data-id="123"] {
  view-transition-name: product-123;
}

Accessibility

Respect Reduced Motion
@media (prefers-reduced-motion: reduce) {
  ::view-transition-group(*),
  ::view-transition-old(*),
  ::view-transition-new(*) {
    animation: none !important;
  }
}
Skip for Screen Readers

View transitions are purely visual; screen readers see the DOM update immediately.

Debugging

Chrome DevTools:

  1. Open Elements panel
  2. Check "Show view transition pseudo-elements"
  3. Inspect ::view-transition-* elements

Add slow-mo for development:

::view-transition-old(*),
::view-transition-new(*) {
  animation-duration: 3s !important;
}

Best Practices

  1. Keep transitions short - 200-400ms for most cases
  2. Use view-transition-name sparingly - Too many can hurt performance
  3. Avoid layout shifts - Elements should have stable sizes
  4. Test without transitions - Ensure DOM updates work without the API
  5. Handle edge cases - Elements only on one page need custom enter/exit
  6. Use types for direction - Forward vs back navigation should feel different

Fallback Strategy

async function navigate(callback) {
  // Feature detection
  if (!document.startViewTransition) {
    callback();
    return;
  }

  try {
    await document.startViewTransition(callback).finished;
  } catch (e) {
    // Transition was skipped or errored
    // DOM is still updated, just not animated
  }
}

Examples

Read the usage examples in the source repository.

View source ↗

Source history

Indexed on
2026-01-20
Index updated
2026-01-28
Branch
main

Index and sync dates describe this directory. View the source repository for code history.

GitHub ↗

chat Comments (0)

chat_bubble_outline

No comments yet. Be the first to share your thoughts!