Agent 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.
Install this agent skill to your Project
npx add-skill https://github.com/majiayu000/claude-skill-registry/tree/main/skills/development/view-transitions
SKILL.md
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:
- Captures snapshots of current state
- Runs your callback to update DOM
- Captures snapshots of new state
- 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:
- Open Elements panel
- Check "Show view transition pseudo-elements"
- Inspect
::view-transition-*elements
Add slow-mo for development:
::view-transition-old(*),
::view-transition-new(*) {
animation-duration: 3s !important;
}
Best Practices
- Keep transitions short - 200-400ms for most cases
- Use
view-transition-namesparingly - Too many can hurt performance - Avoid layout shifts - Elements should have stable sizes
- Test without transitions - Ensure DOM updates work without the API
- Handle edge cases - Elements only on one page need custom enter/exit
- 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
}
}
Didn't find tool you were looking for?