
Css Animation Patterns
- 113 installs
- 14 repo stars
- Updated March 2, 2026
- oakoss/agent-skills
Helps with frontend development tasks during AI-assisted development.
About
css-animation-patterns is a Claude Code skill for frontend development. It helps solo builders move faster with AI-assisted coding.
- css-animation-patterns
- Frontend Development
- AI-coding skill
Css Animation Patterns by the numbers
- 113 all-time installs (skills.sh)
- +5 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #1,025 of 2,245 Frontend Development skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/oakoss/agent-skills --skill css-animation-patternsAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 113 |
|---|---|
| repo stars | ★ 14 |
| Last updated | March 2, 2026 |
| Repository | oakoss/agent-skills ↗ |
What it does
Helps with frontend development tasks during AI-assisted development.
Files
CSS Animation Patterns
Overview
CSS animations and transitions provide hardware-accelerated motion for web interfaces using keyframes, transitions, transforms, and modern scroll-driven and view transition APIs. Animate only composite properties (transform, opacity, filter) for smooth 60fps performance, and always respect prefers-reduced-motion.
The browser rendering pipeline has four stages: Style, Layout, Paint, and Composite. Animating composite-only properties skips Layout and Paint entirely, running on the GPU compositor thread. This is the single most important performance principle for CSS animation.
Modern CSS adds two powerful APIs: scroll-driven animations link keyframe progress to scroll position or element visibility instead of time, and the View Transitions API creates snapshot-based animated transitions between DOM states for both SPAs and MPAs.
When to use: Element state changes, page transitions, scroll-linked effects, loading indicators, micro-interactions, route change animations, reveal-on-scroll patterns, parallax effects, progress indicators tied to scroll.
When NOT to use: Complex physics simulations (use a JS animation library), canvas/WebGL rendering, animations requiring frame-by-frame scripted control (use Web Animations API directly), highly interactive drag-and-drop (use pointer events with JS).
Browser Support Summary
| Feature | Chrome | Firefox | Safari |
|---|---|---|---|
| Transitions, keyframes, transforms | Full | Full | Full |
| Individual transform properties | 104+ | 72+ | 14.1+ |
@starting-style | 117+ | 129+ | 17.5+ |
transition-behavior: allow-discrete | 117+ | 129+ | 17.4+ |
| Scroll-driven animations | 115+ | Not yet | 26+ |
| Same-document view transitions | 111+ | 144+ | 18+ |
| Cross-document view transitions | 126+ | Not yet | 18+ |
view-transition-class | 125+ | 144+ | 18+ |
Use @supports for progressive enhancement with newer features. Always provide a functional non-animated fallback.
Quick Reference
| Pattern | API | Key Points |
|---|---|---|
| State transition | transition: property duration easing | Triggers on property change, composite-only for performance |
| Discrete transition | transition-behavior: allow-discrete | Enables transitions on display, visibility |
| Entry animation | @starting-style { ... } | Initial state for elements appearing in DOM |
| Keyframe animation | @keyframes name + animation shorthand | Multi-step sequences, supports forwards fill mode |
| Transform | transform: translate() scale() rotate() | GPU-composited, no layout recalculation |
| Individual transforms | translate, rotate, scale | Independently animatable with different timings |
| Scroll progress | animation-timeline: scroll() | Links animation to scroll position of a container |
| View progress | animation-timeline: view() | Links animation to element visibility in scrollport |
| Animation range | animation-range: entry 0% entry 100% | Controls which timeline segment drives animation |
| Named scroll timeline | scroll-timeline-name + scroll-timeline-axis | Reusable scroll timeline across elements |
| Named view timeline | view-timeline-name + view-timeline-axis | Reusable view timeline for visibility tracking |
| View transition (SPA) | document.startViewTransition(callback) | Snapshot-based animated DOM updates |
| View transition (MPA) | @view-transition { navigation: auto } | Cross-document transitions, same-origin only |
| Transition naming | view-transition-name: hero | Identifies elements for independent transition groups |
| Transition classes | view-transition-class: card | Groups named elements for shared transition styles |
| Transition types | startViewTransition({ types: [...] }) | Conditional styling based on navigation direction |
| GPU hint | will-change: transform | Promotes element to compositor layer, use sparingly |
| Motion preference | @media (prefers-reduced-motion: reduce) | Disable or simplify animations for accessibility |
| Custom easing | cubic-bezier() or linear() | Fine-tuned timing curves, linear() for multi-point easing |
| Step easing | steps(n, jump-term) | Frame-by-frame discrete animation |
| Animation composition | animation-composition: accumulate | Controls how multiple animations combine on same property |
| Staggered delay | animation-delay: calc(var(--i) * 60ms) | Per-element delay using CSS custom properties |
| Render containment | contain: layout style | Isolates rendering scope for better animation perf |
| Content visibility | content-visibility: auto | Skips rendering of off-screen content |
Common Mistakes
| Mistake | Correct Pattern |
|---|---|
Animating width, height, top, left | Use transform: translate() and scale() for layout-free animation |
Adding will-change to every element | Apply only to elements that animate frequently, remove after animation |
Missing prefers-reduced-motion handling | Wrap motion in @media (prefers-reduced-motion: no-preference) |
Using translateZ(0) hack everywhere | Use will-change instead, and only when needed |
Declaring animation-timeline before animation shorthand | Declare animation-timeline after animation (shorthand resets it to auto) |
Setting animation-duration for scroll-driven animations | Duration is scroll-controlled; use auto or omit, set 1ms for Firefox compat |
Forgetting view-transition-name must be unique | Each participating element needs a distinct name per page snapshot |
| Not providing fallbacks for scroll-driven animations | Use @supports (animation-timeline: scroll()) for progressive enhancement |
Animating background-color expecting GPU compositing | Only transform, opacity, and filter are reliably GPU-composited |
Using transition: all | Specify exact properties to avoid unexpected transitions and performance hits |
| Interleaving DOM reads and writes in JS animations | Batch reads first, then writes, or use requestAnimationFrame |
Not using flushSync with React view transitions | React batches updates; wrap navigate() in flushSync inside the callback |
Calling startViewTransition without feature check | Always guard with if (!document.startViewTransition) fallback |
Delegation
- Animation implementation: Use
Exploreagent to discover patterns in reference files - Performance audit: Use
Taskagent to review animation performance across components - Accessibility review: Use
Taskagent to verifyprefers-reduced-motioncoverage - Code review: Delegate to
code-revieweragent for animation-related PR reviews
If the ux-designer skill is available, delegate visual motion design decisions to it.Otherwise, recommend: npx skills add oakoss/agent-skills --skill ux-designerReferences
- Transitions, keyframes, and animation properties
- Transforms and performance optimization
- Scroll-driven animations
- View Transitions API
Scroll-Driven Animations
Overview
Scroll-driven animations link CSS keyframe animations to scroll position instead of time. They run on the compositor thread, avoiding main-thread jank that JavaScript scroll listeners produce.
Two timeline types exist:
- Scroll progress timeline (
scroll()): Tracks how far a container has scrolled - View progress timeline (
view()): Tracks an element's visibility within a scrollport
Scroll Progress Timeline
The scroll() function creates an anonymous timeline tied to a scrolling container's position.
Basic Scroll Progress
@keyframes progress-bar {
from {
transform: scaleX(0);
}
to {
transform: scaleX(1);
}
}
.reading-progress {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 3px;
background: var(--accent);
transform-origin: left;
animation: progress-bar linear both;
animation-timeline: scroll();
}scroll() Parameters
.element {
animation-timeline: scroll(<scroller> <axis>);
}| Parameter | Values | Default |
|---|---|---|
<scroller> | nearest, root, self | nearest |
<axis> | block, inline, x, y | block |
.parallax-bg {
animation: parallax linear both;
animation-timeline: scroll(root block);
}
@keyframes parallax {
from {
transform: translateY(0);
}
to {
transform: translateY(-200px);
}
}Named Scroll Timeline
Named timelines allow one container's scroll to drive animations on distant elements.
.scroll-container {
scroll-timeline-name: --main-scroll;
scroll-timeline-axis: block;
overflow-y: auto;
}
.distant-element {
animation: fade-in linear both;
animation-timeline: --main-scroll;
}
@keyframes fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}View Progress Timeline
The view() function creates a timeline based on an element's visibility within its nearest scroll container.
Basic Reveal Animation
@keyframes reveal {
from {
opacity: 0;
transform: translateY(40px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.card {
animation: reveal linear both;
animation-timeline: view();
animation-range: entry 0% entry 100%;
}view() Parameters
.element {
animation-timeline: view(<axis> <inset>);
}| Parameter | Values | Default |
|---|---|---|
<axis> | block, inline, x, y | block |
<inset> | <length-percentage> (start and end insets) | auto |
.element {
animation-timeline: view(block 20% 10%);
}The insets adjust the visibility box. A 20% start inset means the element's visibility timeline begins when it is 20% into the scrollport from the entry edge.
Named View Timeline
.observed-element {
view-timeline-name: --card-visibility;
view-timeline-axis: block;
}
.related-indicator {
animation: highlight linear both;
animation-timeline: --card-visibility;
}
@keyframes highlight {
from {
background-color: transparent;
}
to {
background-color: var(--highlight);
}
}Animation Range
The animation-range property controls which portion of the timeline drives the animation.
Range Keywords
| Keyword | When |
|---|---|
cover | Full visibility range, from first visible pixel to fully exited |
contain | Element is fully visible inside the scrollport |
entry | Element entering the scrollport |
exit | Element exiting the scrollport |
entry-crossing | Element's leading edge crossing the entry edge |
exit-crossing | Element's trailing edge crossing the exit edge |
Range Syntax
.element {
animation-range: entry 0% entry 100%;
}
.element-full {
animation-range: cover 0% cover 100%;
}
.element-partial {
animation-range: entry 25% cover 50%;
}Shorthand Examples
.fade-in-on-entry {
animation: reveal linear both;
animation-timeline: view();
animation-range: entry;
}
.fade-out-on-exit {
animation: fade-out linear both;
animation-timeline: view();
animation-range: exit;
}Duration and Timing
Scroll-driven animations are controlled by scroll position, not time. The animation-duration is effectively ignored, but browsers may require it to be non-zero.
.element {
animation: reveal linear both;
animation-timeline: view();
}Use linear as the timing function since the scroll position already provides the progression. Non-linear easing can still be used for stylistic effect within the scroll range.
Practical Patterns
Parallax Background
.hero {
position: relative;
overflow: hidden;
}
.hero-bg {
position: absolute;
inset: -20% 0;
animation: parallax-shift linear both;
animation-timeline: scroll(root);
}
@keyframes parallax-shift {
from {
transform: translateY(0);
}
to {
transform: translateY(-15%);
}
}Sticky Header Shrink
.header {
position: sticky;
top: 0;
animation: shrink-header linear both;
animation-timeline: scroll(root);
animation-range: 0px 200px;
}
@keyframes shrink-header {
from {
padding-block: 1.5rem;
}
to {
padding-block: 0.5rem;
}
}Staggered Reveal
.grid-item {
animation: reveal linear both;
animation-timeline: view();
animation-range: entry 0% entry 100%;
}
.grid-item:nth-child(2) {
animation-delay: 50ms;
}
.grid-item:nth-child(3) {
animation-delay: 100ms;
}Progressive Enhancement
Scroll-driven animations are not supported in all browsers. Always provide fallback styles.
Feature Detection in CSS
.card {
opacity: 1;
transform: none;
}
@supports (animation-timeline: view()) {
.card {
animation: reveal linear both;
animation-timeline: view();
animation-range: entry;
}
}IntersectionObserver Fallback
function setupRevealFallback(selector: string) {
if (CSS.supports('animation-timeline', 'view()')) return;
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
entry.target.classList.add('revealed');
observer.unobserve(entry.target);
}
});
},
{ threshold: 0.1 },
);
document.querySelectorAll(selector).forEach((el) => observer.observe(el));
}
setupRevealFallback('.card');.card {
opacity: 0;
transform: translateY(40px);
transition:
opacity 400ms ease-out,
transform 400ms ease-out;
}
.card.revealed {
opacity: 1;
transform: translateY(0);
}Accessibility
Always disable scroll-driven animations for users who prefer reduced motion.
@media (prefers-reduced-motion: reduce) {
.card {
animation: none;
opacity: 1;
transform: none;
}
}Key Ordering Rule
The animation shorthand resets animation-timeline to auto. Always declare animation-timeline after animation.
.element {
animation: reveal linear both;
animation-timeline: view();
}Transforms and Performance
CSS Transforms
Transforms modify an element's visual rendering without affecting document layout. They are composited on the GPU, making them ideal for animation.
2D Transforms
.card {
transform: translateX(20px);
}
.icon {
transform: rotate(45deg);
}
.thumbnail {
transform: scale(1.1);
}
.combined {
transform: translate(-50%, -50%) rotate(15deg) scale(0.9);
}3D Transforms
.scene {
perspective: 800px;
}
.card-3d {
transform: rotateY(180deg);
transform-style: preserve-3d;
backface-visibility: hidden;
}Individual Transform Properties
Modern CSS supports individual transform properties, which are easier to animate independently.
.element {
translate: 20px 10px;
rotate: 45deg;
scale: 1.2;
}
.element:hover {
translate: 20px -10px;
rotate: 90deg;
scale: 1.4;
}These can be transitioned independently with different timings:
.element {
transition:
translate 300ms ease-out,
rotate 500ms ease-in-out,
scale 200ms ease-out;
}Transform Origin
.element {
transform-origin: top left;
transform: rotate(45deg);
}
.centered {
transform-origin: center center;
transform: scale(1.5);
}The Rendering Pipeline
Understanding the browser rendering pipeline is key to animation performance.
Style -> Layout -> Paint -> Composite| Stage | Triggered By | Cost |
|---|---|---|
| Layout (Reflow) | width, height, top, left, margin, padding, font-size | Highest |
| Paint (Repaint) | background, color, border, box-shadow, border-radius | High |
| Composite | transform, opacity, filter | Lowest |
Animating composite-only properties skips Layout and Paint entirely, running on the GPU compositor thread.
Composite-Only Properties
These properties can be animated without triggering layout or paint:
| Property | Use Case |
|---|---|
transform | Movement, rotation, scaling |
opacity | Fade in/out |
filter | Blur, brightness, contrast effects |
Movement: Transform vs Position
.slow {
transition: left 300ms ease-out;
position: absolute;
left: 100px;
}
.fast {
transition: transform 300ms ease-out;
transform: translateX(100px);
}The transform version runs entirely on the compositor thread. The left version triggers layout recalculation on every frame.
Sizing: Transform vs Dimensions
.slow {
transition:
width 300ms,
height 300ms;
width: 200px;
height: 200px;
}
.fast {
transition: transform 300ms;
transform: scale(2);
}The will-change Property
The will-change property hints to the browser that an element will change, prompting it to promote the element to its own compositor layer in advance.
Correct Usage
.card {
transition: transform 200ms ease-out;
}
.card:hover {
will-change: transform;
}
.card:active {
transform: scale(0.98);
}Applying and Removing via JavaScript
const card = document.querySelector('.card');
card.addEventListener('pointerenter', () => {
card.style.willChange = 'transform';
});
card.addEventListener('transitionend', () => {
card.style.willChange = 'auto';
});Common Misuse
*,
*::before,
*::after {
will-change: transform, opacity;
}This creates compositor layers for every element, consuming significant GPU memory and potentially degrading performance. Each layer requires texture memory on the GPU.
When to Use will-change
| Scenario | Recommendation |
|---|---|
| Element animates frequently (hover, scroll) | Apply on parent hover or via JS before animation |
| Element animates once on page load | Do not use, animation is already optimized |
| Many elements animate simultaneously | Apply selectively to the most complex elements |
| Fixed/sticky elements with transforms | Consider applying persistently |
Layout Thrashing
Layout thrashing occurs when JavaScript alternates between reading layout properties and writing style changes, forcing the browser to recalculate layout multiple times per frame.
Problem: Interleaved Reads and Writes
const items = document.querySelectorAll('.item');
items.forEach((item) => {
const height = item.offsetHeight;
item.style.height = `${height * 2}px`;
});Solution: Batch Reads Then Writes
const items = document.querySelectorAll('.item');
const heights = Array.from(items, (item) => item.offsetHeight);
items.forEach((item, i) => {
item.style.height = `${heights[i] * 2}px`;
});Solution: Use requestAnimationFrame
function updateLayout(element: HTMLElement, newHeight: number) {
requestAnimationFrame(() => {
element.style.height = `${newHeight}px`;
});
}Properties That Trigger Layout
Avoid animating these properties:
| Property | Alternative |
|---|---|
width / height | transform: scale() |
top / left / right / bottom | transform: translate() |
margin | transform: translate() |
padding | Inner element with transform |
border-width | box-shadow or outline |
font-size | transform: scale() on container |
Reading Layout Properties Triggers Forced Reflow
These properties and methods force the browser to calculate layout when accessed:
offsetTop,offsetLeft,offsetWidth,offsetHeightscrollTop,scrollLeft,scrollWidth,scrollHeightclientTop,clientLeft,clientWidth,clientHeightgetComputedStyle()getBoundingClientRect()
Cache these values when reading multiple times in a single frame.
Contain Property for Isolation
The contain property limits the browser's rendering scope, preventing changes inside an element from affecting the rest of the page.
.animated-container {
contain: layout style;
}| Value | Effect |
|---|---|
layout | Element's layout is independent of the rest of the page |
paint | Element's contents do not render outside its bounds |
style | Counters and quotes are scoped to the element |
size | Element can be sized without examining its children |
content | Shorthand for layout paint style |
strict | Shorthand for layout paint style size |
Content Visibility
The content-visibility property skips rendering of off-screen content, improving initial page load and scroll performance.
.section {
content-visibility: auto;
contain-intrinsic-size: auto 500px;
}The contain-intrinsic-size provides a placeholder size so the scrollbar does not jump when content is rendered.
Performance Debugging
Chrome DevTools
1. Open Performance panel, enable "Screenshots" and "Web Vitals" 2. Record an animation interaction 3. Look for long frames (red bars) in the Frames section 4. Check the "Rendering" tab > enable "Paint flashing" to see repaint areas 5. Enable "Layer borders" to visualize compositor layers
Key Metrics
| Metric | Target |
|---|---|
| Frame duration | Under 16.67ms (60fps) |
| Paint areas | Minimal, no full-page repaints during animation |
| Compositor layers | Only elements that need them |
| GPU memory | Monitor in DevTools Layers panel |
Transitions and Keyframes
CSS Transitions
Transitions animate property changes between two states. They require a trigger (hover, class toggle, media query change).
Transition Shorthand
.card {
transition:
transform 200ms ease-out,
opacity 200ms ease-out;
}
.card:hover {
transform: translateY(-4px);
opacity: 0.9;
}Individual Properties
.element {
transition-property: transform, opacity;
transition-duration: 300ms;
transition-timing-function: ease-in-out;
transition-delay: 0ms;
}Transition Behavior for Discrete Properties
The transition-behavior property enables transitions on discrete properties like display and visibility.
.tooltip {
opacity: 0;
display: none;
transition:
opacity 200ms ease-out,
display 200ms ease-out allow-discrete;
}
.tooltip.visible {
opacity: 1;
display: block;
}The allow-discrete keyword tells the browser to flip display at the right moment in the transition.
Starting Style
The @starting-style rule defines the initial state for transitions when an element first appears in the DOM or transitions from display: none.
.dialog {
opacity: 1;
transform: translateY(0);
transition:
opacity 300ms ease-out,
transform 300ms ease-out;
@starting-style {
opacity: 0;
transform: translateY(20px);
}
}Keyframe Animations
Keyframes define multi-step animation sequences that run independently of state changes.
Basic Keyframe
@keyframes fade-in {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.element {
animation: fade-in 300ms ease-out;
}Multi-Step Keyframe
@keyframes bounce {
0% {
transform: translateY(0);
}
40% {
transform: translateY(-20px);
}
60% {
transform: translateY(-10px);
}
80% {
transform: translateY(-5px);
}
100% {
transform: translateY(0);
}
}Animation Shorthand
.element {
animation: bounce 600ms cubic-bezier(0.36, 0.07, 0.19, 0.97) both;
}Order: name duration timing-function delay iteration-count direction fill-mode play-state.
Individual Animation Properties
.spinner {
animation-name: spin;
animation-duration: 1s;
animation-timing-function: linear;
animation-delay: 0ms;
animation-iteration-count: infinite;
animation-direction: normal;
animation-fill-mode: none;
animation-play-state: running;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}Fill Modes
| Value | Behavior |
|---|---|
none | No styles applied before or after animation |
forwards | Retains final keyframe values after completion |
backwards | Applies first keyframe values during delay |
both | Combines forwards and backwards |
.element {
animation: slide-in 400ms ease-out forwards;
}Timing Functions
Built-in Keywords
| Keyword | Equivalent cubic-bezier() | Use Case |
|---|---|---|
ease | cubic-bezier(0.25, 0.1, 0.25, 1) | General purpose, default |
ease-in | cubic-bezier(0.42, 0, 1, 1) | Elements exiting |
ease-out | cubic-bezier(0, 0, 0.58, 1) | Elements entering |
ease-in-out | cubic-bezier(0.42, 0, 0.58, 1) | Symmetrical motion |
linear | cubic-bezier(0, 0, 1, 1) | Constant speed |
Custom Cubic Bezier
.element {
transition: transform 300ms cubic-bezier(0.34, 1.56, 0.64, 1);
}The linear() Function
The linear() function defines a piecewise linear easing with multiple control points, useful for spring-like or complex curves.
.spring {
transition: transform 600ms
linear(
0,
0.006,
0.025,
0.058,
0.104,
0.163,
0.234,
0.315,
0.404,
0.498,
0.592,
0.682,
0.764,
0.834,
0.893,
0.939,
0.972,
0.993,
1.002,
1.001,
0.994,
0.986,
0.979,
0.975,
0.974,
0.976,
0.979,
0.984,
0.989,
0.994,
0.998,
1.001,
1.002,
1.001,
1
);
}Steps
.typewriter {
animation: typing 3s steps(20, end);
}Animation Composition
The animation-composition property controls how multiple animations combine when targeting the same property.
.element {
animation:
move 1s ease-out,
grow 1s ease-out;
animation-composition: accumulate;
}
@keyframes move {
to {
transform: translateX(100px);
}
}
@keyframes grow {
to {
transform: scale(1.5);
}
}| Value | Behavior |
|---|---|
replace | Animation value replaces the underlying value (default) |
add | Animation value is added to the underlying value |
accumulate | Animation value is combined with the underlying value |
Staggered Animations with Custom Properties
.list-item {
animation: fade-slide-in 400ms ease-out backwards;
animation-delay: calc(var(--index) * 60ms);
}<ul>
<li class="list-item" style="--index: 0">First</li>
<li class="list-item" style="--index: 1">Second</li>
<li class="list-item" style="--index: 2">Third</li>
</ul>@keyframes fade-slide-in {
from {
opacity: 0;
transform: translateY(10px);
}
}Accessibility: Reduced Motion
Always provide a reduced-motion alternative.
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}For more nuanced control, opt in to motion rather than opting out:
.element {
opacity: 1;
transform: none;
}
@media (prefers-reduced-motion: no-preference) {
.element {
animation: fade-in 300ms ease-out;
}
}Controlling Animations with JavaScript
const element = document.querySelector('.animated');
element.addEventListener('animationend', () => {
element.classList.remove('animate');
});
element.addEventListener('transitionend', () => {
element.classList.add('settled');
});Web Animations API Integration
const animation = element.animate(
[
{ transform: 'translateX(0)', opacity: 1 },
{ transform: 'translateX(100px)', opacity: 0 },
],
{ duration: 300, easing: 'ease-out', fill: 'forwards' },
);
await animation.finished;View Transitions API
Overview
The View Transitions API creates animated transitions between DOM states by capturing snapshots of the old and new states and cross-fading between them. It works for both same-document (SPA) and cross-document (MPA) navigation.
Same-document view transitions are Baseline across all major browsers. Cross-document view transitions work in Chrome and Safari but are not yet supported in Firefox.
Same-Document View Transitions (SPA)
Basic Usage
async function updateContent(newData: string) {
const transition = document.startViewTransition(() => {
document.querySelector('.content')!.innerHTML = newData;
});
await transition.finished;
}The callback passed to startViewTransition() performs the DOM update. The browser:
1. Captures a snapshot of the old state 2. Runs the callback to update the DOM 3. Captures a snapshot of the new state 4. Cross-fades between the snapshots
With Async Updates
const transition = document.startViewTransition(async () => {
const data = await fetchNewContent();
renderContent(data);
});ViewTransition Object
const transition = document.startViewTransition(updateDOM);
transition.ready.then(() => {
// Snapshots captured, animation about to start
});
transition.updateCallbackDone.then(() => {
// DOM update callback has completed
});
transition.finished.then(() => {
// Transition animation has completed
});Naming Elements for Independent Transitions
By default, the entire page cross-fades as a single group. Use view-transition-name to identify elements that should transition independently.
.hero-image {
view-transition-name: hero;
}
.page-title {
view-transition-name: title;
}Each named element gets its own snapshot group, enabling independent position and size animations between states.
Rules for view-transition-name
- Each value must be unique on the page at the time of snapshot capture
- The value
nonedisables view transition participation - The special value
match-elementauto-generates unique names, useful for lists
.list-item {
view-transition-name: match-element;
}Styling View Transitions
View transitions create a pseudo-element tree that can be styled with CSS.
Pseudo-Element Structure
::view-transition
::view-transition-group(name)
::view-transition-image-pair(name)
::view-transition-old(name)
::view-transition-new(name)Custom Transition Animation
::view-transition-old(hero) {
animation: fade-out 300ms ease-out;
}
::view-transition-new(hero) {
animation: fade-in 300ms ease-out;
}
@keyframes fade-out {
to {
opacity: 0;
transform: scale(0.9);
}
}
@keyframes fade-in {
from {
opacity: 0;
transform: scale(1.1);
}
}Controlling Duration and Easing
::view-transition-group(hero) {
animation-duration: 400ms;
animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
}Slide Transition
@keyframes slide-out-left {
to {
transform: translateX(-100%);
}
}
@keyframes slide-in-right {
from {
transform: translateX(100%);
}
}
::view-transition-old(root) {
animation: slide-out-left 300ms ease-in-out;
}
::view-transition-new(root) {
animation: slide-in-right 300ms ease-in-out;
}View Transition Classes
The view-transition-class property groups multiple named elements to share transition styles without targeting each name individually.
.card {
view-transition-name: match-element;
view-transition-class: card;
}
::view-transition-group(*.card) {
animation-duration: 300ms;
animation-timing-function: ease-out;
}The *.card selector matches all view transition groups with the card class.
View Transition Types
Types allow conditional styling based on the kind of navigation.
document.startViewTransition({
update: updateDOM,
types: ['slide-forward'],
});html:active-view-transition-type(slide-forward) {
&::view-transition-old(root) {
animation: slide-out-left 300ms ease-in-out;
}
&::view-transition-new(root) {
animation: slide-in-right 300ms ease-in-out;
}
}
html:active-view-transition-type(slide-back) {
&::view-transition-old(root) {
animation: slide-out-right 300ms ease-in-out;
}
&::view-transition-new(root) {
animation: slide-in-left 300ms ease-in-out;
}
}Cross-Document View Transitions (MPA)
Cross-document view transitions animate navigations between separate HTML pages on the same origin.
Opt-In via CSS
Both the source and destination pages must opt in:
@view-transition {
navigation: auto;
}Naming Elements Across Pages
Elements with matching view-transition-name values on both pages will animate between their positions.
/* Page A */
.product-image {
view-transition-name: product;
}
/* Page B */
.product-detail-image {
view-transition-name: product;
}Customizing Cross-Document Transitions
@view-transition {
navigation: auto;
}
::view-transition-group(product) {
animation-duration: 400ms;
}
::view-transition-old(root) {
animation: fade-out 200ms ease-out;
}
::view-transition-new(root) {
animation: fade-in 200ms ease-out;
}Page Lifecycle Events
window.addEventListener('pagereveal', (event) => {
const transition = (event as PageRevealEvent).viewTransition;
if (transition) {
// Customize the incoming transition
}
});
window.addEventListener('pageswap', (event) => {
const transition = (event as PageSwapEvent).viewTransition;
if (transition) {
// Customize the outgoing transition
}
});Framework Integration
React with flushSync
React batches state updates asynchronously. Use flushSync to ensure the DOM updates synchronously within the view transition callback.
import { flushSync } from 'react-dom';
function NavigationLink({
to,
children,
}: {
to: string;
children: React.ReactNode;
}) {
const navigate = useNavigate();
const handleClick = () => {
if (!document.startViewTransition) {
navigate(to);
return;
}
document.startViewTransition(() => {
flushSync(() => {
navigate(to);
});
});
};
return <a onClick={handleClick}>{children}</a>;
}Conditional Application of view-transition-name
Assign view-transition-name dynamically to avoid uniqueness conflicts:
function ProductCard({
product,
isActive,
}: {
product: Product;
isActive: boolean;
}) {
return (
<div
style={{
viewTransitionName: isActive ? 'product-hero' : 'none',
}}
>
<img src={product.image} alt={product.name} />
</div>
);
}Progressive Enhancement
Always check for API support before using view transitions.
function navigateWithTransition(updateFn: () => void) {
if (!document.startViewTransition) {
updateFn();
return;
}
document.startViewTransition(updateFn);
}Feature Detection in CSS
@supports (view-transition-name: test) {
.hero {
view-transition-name: hero;
}
}Accessibility
Disable view transition animations for users who prefer reduced motion.
@media (prefers-reduced-motion: reduce) {
::view-transition-group(*),
::view-transition-old(*),
::view-transition-new(*) {
animation: none !important;
}
}Active View Transition Selector
The :active-view-transition pseudo-class matches the root element while a view transition is running, useful for preventing interactions during transitions.
html:active-view-transition {
pointer-events: none;
}