../

Animation

UI motion on the web as of September 2026: timing and easing guidelines, CSS transitions and keyframes, entry and exit animations, scroll-driven animations, view transitions, and when to use Motion or GSAP. The JS API is covered in Web Animations, the CSS basics in CSS.

Motion principles

Motion should explain something: where an element came from, what changed, what it's connected to, or that an action worked. If it only decorates, cut it or keep it short.

PurposeExample
Feedbacka button press, a toggle, a validation shake
Orientationa panel slides in from the edge it lives on
Continuitya thumbnail morphs into the detail view
Hierarchystagger list items in reading order
Statusspinner, progress bar, skeleton shimmer
Delightrare, and never in the way of a repeated task
UseDurationNote
Hover, press, color change100–150msanything slower feels laggy
Toggle, checkbox, small icon150–200ms
Tooltip, dropdown, popover150–250msexit about 30% faster than entry
Modal, drawer, sheet250–400msscale with the distance traveled
Page or route transition300–500msoverlap old and new content
Large or expressive motion500–1000msonboarding, empty states, rarely
Stagger step20–50ms per itemcap the total at about 300ms

On mobile, shorten by roughly 20–30%. Anything the user triggers many times a day should be near-instant.

EasingCurveUse for
Ease-out (decelerate)cubic-bezier(0.2, 0, 0, 1)elements entering; most UI motion
Ease-in (accelerate)cubic-bezier(0.3, 0, 1, 1)elements leaving the screen for good
Ease-in-outcubic-bezier(0.4, 0, 0.2, 1)on-screen moves from A to B
Linearlinearprogress, spinners, color and opacity loops, scroll-linked motion
Springlinear(...) or a JS springdrags, gestures, anything interruptible
Stepssteps(4)sprite sheets, typewriter, ticking clocks
Classic principleIn UI
Timingdurations match the size and weight of the change
Slow in and slow outnever use linear for things that move
Anticipationa slight dip before a sheet rises; a hover state before a click
Follow-through and overlapchildren settle a beat after the parent; springs overshoot a little
Staginganimate one thing at a time and dim the rest (modal backdrop)
Arcsmoving along a curve reads as natural (FAB to sheet)
Squash and stretcha subtle scale on press (0.97), not rubbery
Secondary actionan icon rotates as its menu opens
Exaggerationkeep it small in product UI; error shakes are the exception
Appealconsistent tokens make motion feel like one system

Transitions

A transition animates between two computed values when a property changes (a hover, a class toggle, [open]).

PropertyValue
transition-propertyopacity, translate, or all (avoid: animates things you didn't intend)
transition-duration200ms
transition-timing-functionease-out, cubic-bezier(...), linear(...), steps(n)
transition-delay50ms; negative values start partway through
transition-behaviorallow-discrete: also transition discrete properties such as display (Baseline 2024)
transitionshorthand: opacity 200ms ease-out, translate 200ms ease-out
.btn {
  transition:
    background-color 150ms ease-out,
    scale 100ms ease-out;
}
.btn:hover { background-color: var(--brand-hover); }
.btn:active { scale: 0.97; }
transition.html
<style>
  .btn { padding: 10px 16px; border: 0; border-radius: 6px;
    font: inherit; color: var(--bg);
    background-color: var(--graph-0);
    transition:
      background-color 150ms ease-out,
      translate 200ms ease-out,
      scale 100ms ease-out; }
  .btn:hover { background-color: var(--graph-3);
    translate: 0 -3px; }
  .btn:active { scale: 0.97; }
</style>
<button class="btn">Hover, then press</button>
Result
Discrete propertyWith allow-discrete
display: nonethe element stays visible until the exit transition ends, then flips
overlaykeeps a closing dialog or popover in the top layer while it animates (Chromium only)
content-visibilitylets details content animate closed

Transitioning display works in Chromium and Safari 18+. Firefox supports allow-discrete but not animating display yet, so exits there snap closed, which is an acceptable fallback.

@starting-style

An element that first renders, or goes from display: none to shown, has no "before" style, so it wouldn't normally transition. @starting-style provides one (Baseline 2024).

.toast {
  opacity: 1;
  translate: 0 0;
  transition:
    opacity 200ms ease-out,
    translate 200ms ease-out;
 
  @starting-style {
    opacity: 0;
    translate: 0 1rem;
  }
}

A @starting-style entry only plays when the element starts rendering, so the demo re-inserts the toast on hover by flipping display. allow-discrete keeps it visible while it fades out again (Chromium and Safari; Firefox snaps closed).

starting-style.html
<style>
  .zone { height: 96px; padding: 10px; border-radius: 6px;
    border: 1px dashed var(--muted); }
  .toast { display: none; width: fit-content;
    margin-top: 8px; padding: 8px 12px; border-radius: 6px;
    background: var(--graph-2); color: var(--bg);
    opacity: 0; translate: 0 12px;
    transition: opacity 250ms ease-out,
      translate 250ms ease-out,
      display 250ms allow-discrete; }
  .zone:hover .toast {
    display: block; opacity: 1; translate: 0 0;
    @starting-style { opacity: 0; translate: 0 12px; }
  }
</style>
<div class="zone">Hover here: display goes from none to block
  <div class="toast">Saved</div>
</div>
Result

The same pattern animates popovers, dialogs, and anything that gets hidden removed. Tailwind writes it as starting:opacity-0 (Tailwind CSS).

Animating to height: auto

:root { interpolate-size: allow-keywords; }  /* Chromium */
 
details::details-content {
  block-size: 0;
  overflow: clip;
  transition:
    block-size 250ms ease-out,
    content-visibility 250ms allow-discrete;
}
details[open]::details-content { block-size: auto; }

Without interpolate-size the panel just snaps open and closed. The portable alternative is a grid row going from 0fr to 1fr:

.collapse { display: grid; grid-template-rows: 0fr; }
.collapse.open { grid-template-rows: 1fr; }
.collapse > .inner { overflow: hidden; }
.collapse { transition: grid-template-rows 250ms ease-out; }

Keyframe animations

@keyframes pulse {
  0%, 100% { opacity: 1; }
  50% { opacity: 0.4; }
}
@keyframes slide-in {
  from { translate: -100% 0; }   /* "to" = current style */
}
 
.skeleton { animation: pulse 1.5s ease-in-out infinite; }
.drawer { animation: slide-in 300ms cubic-bezier(.2,0,0,1); }
keyframes.html
<style>
  @keyframes pulse {
    0%, 100% { opacity: 1; }
    50% { opacity: 0.4; }
  }
  @keyframes spin { to { rotate: 1turn; } }
 
  .row { display: flex; gap: 14px; align-items: center; }
  .spinner { flex: none; width: 32px; height: 32px;
    border-radius: 50%; border: 4px solid var(--chip);
    border-top-color: var(--graph-0);
    animation: spin 0.8s linear infinite; }
  .lines { flex: 1; display: grid; gap: 8px; }
  .lines i { height: 12px; border-radius: 6px;
    background: var(--chip);
    animation: pulse 1.5s ease-in-out infinite; }
  .lines i:last-child { width: 60%; }
</style>
<div class="row">
  <div class="spinner"></div>
  <div class="lines"><i></i><i></i><i></i></div>
</div>
Result
PropertyValues
animation-namethe @keyframes name, or none
animation-duration300ms; auto (0, or the timeline length when scroll-driven)
animation-timing-functionper keyframe interval, not across the whole animation
animation-delaynegative values start partway through
animation-iteration-count1, 3, infinite
animation-directionnormal, reverse, alternate, alternate-reverse
animation-fill-modeforwards keeps the end state, backwards applies the first frame during the delay, both
animation-play-staterunning, paused
animation-compositionreplace, add, accumulate: combine with the underlying value
animation-timelineauto, scroll(), view(), --named
animation-rangestart and end on a scroll or view timeline
animationshorthand: name duration easing delay count direction fill state
TipWhy
Omit from or toit uses the element's current style, so one keyframe is enough
Use translate, scale, rotateindividual properties compose, so hover scale doesn't wipe out translate
Set the timing function inside a keyframeeases that segment only
animation: none then reflowthe classic restart hack; the Web Animations API is cleaner
Animate a registered custom property@property --angle makes gradients and angles animatable
Listen for animationend / transitionendor el.getAnimations() and await .finished

Easing functions

progress ↑ linear (0,0,1,1) ease (.25,.1,.25,1) ease-in (.42,0,1,1) ease-out (0,0,.58,1) back-out (.34,1.56,.64,1) x: time, y: progress; steep means fast, above the dotted line overshoots
Easing curves: x is time, y is progress
easing.html
<style>
  .lane { container-type: inline-size; position: relative;
    height: 26px; margin-bottom: 6px; padding-left: 80px;
    border-radius: 4px; background: var(--chip);
    font: 11px/26px monospace; text-indent: -72px; }
  .lane i { position: absolute; top: 3px; left: 80px;
    width: 20px; height: 20px; border-radius: 4px;
    background: var(--graph-0); }
  @media (prefers-reduced-motion: no-preference) {
    .lane i { animation: go 1.6s var(--e) infinite
      alternate; }
  }
  /* stop 30px short of the end to leave room to overshoot */
  @keyframes go { to { translate: calc(100cqi - 50px); } }
</style>
<div class="lane" style="--e: linear">linear<i></i></div>
<div class="lane" style="--e: ease">ease<i></i></div>
<div class="lane" style="--e: ease-in">ease-in<i></i></div>
<div class="lane" style="--e: ease-out">ease-out<i></i></div>
<div class="lane" style="--e: cubic-bezier(.34,1.56,.64,1)">
  back-out<i></i></div>
Result
FunctionShape
easecubic-bezier(0.25, 0.1, 0.25, 1), the default
ease-in / ease-out / ease-in-out(0.42,0,1,1) / (0,0,0.58,1) / (0.42,0,0.58,1): mild
cubic-bezier(x1, y1, x2, y2)x values in 0–1; y outside that range overshoots
cubic-bezier(0.34, 1.56, 0.64, 1)"back out": a small overshoot
steps(5, jump-end)discrete jumps; jump-none, jump-both, start, end
linear(0, 0.25 40%, 1)a piecewise-linear curve through the given points (Baseline 2023)

linear() can approximate any curve, including springs and bounces, with enough points. Each stop is output [input%]; the input percentages are optional and interpolate evenly when left out. Generate them with a tool (see References) rather than by hand.

:root {
  /* spring: damping ratio 0.6, settles in about 700ms */
  --ease-spring: linear(
    0, 0.145, 0.436, 0.721, 0.93, 1.048, 1.092, 1.089,
    1.065, 1.036, 1.013, 0.999, 0.992, 0.991, 0.993,
    0.996, 1
  );
  --ease-out: cubic-bezier(0.2, 0, 0, 1);
  --ease-in: cubic-bezier(0.3, 0, 1, 1);
}
.sheet { transition: translate 700ms var(--ease-spring); }

A CSS spring has a fixed duration and can't carry velocity over when it's interrupted. For drags and gestures use a JS spring (Motion).

Performance

The browser renders in stages: style, layout, paint, composite. The cheapest animations skip straight to composite.

Style Layout Paint Composite computed values sizes, positions fill in pixels move layers (GPU) skip skip skip skip width height top margin grid-* expensive color background box-shadow moderate transform translate scale opacity cheap every frame reruns each stage from the first one the property triggers
Which stages each kind of property triggers
AnimatingTriggersCost
transform, translate, scale, rotate, opacitycomposite only (usually on the GPU)cheap; stays smooth while the main thread is busy
filter, backdrop-filtercomposite, but expensive per pixelfine for small areas
color, background-color, box-shadow, clip-pathpaintacceptable when small
width, height, top, left, margin, padding, grid-template-*layout, then paintexpensive; use FLIP or a transform
ToolGuidance
will-change: transformpromotes to its own layer; add it just before animating and remove it after; each layer costs memory
contain: layout paintlimits the work a change can cause
requestAnimationFramebatch DOM reads, then writes, once per frame
Avoid layout thrashreading offsetHeight after a write forces a synchronous layout
DevToolsPerformance panel, Rendering then "Paint flashing" and "Layer borders", Animations panel

FLIP

Animate a layout change with transforms: record the First box, make the change and measure the Last box, Invert with a transform, then Play back to zero. See the FLIP recipe below.

First Last Invert Play translate: -dx to 0 read the rect change the DOM, read the new rect transform it back to where it was animate the transform away layout changes once; only transform animates, so the move stays cheap
FLIP: First, Last, Invert, Play

Motion's layout prop and GSAP's Flip plugin do this for you, and view transitions do it at the browser level.

Scroll-driven animations

A keyframe animation whose progress follows scrolling instead of time. With compositor-only properties it runs off the main thread. Supported in Chromium 115+ and Safari 26; Firefox has it behind a flag, so treat it as an enhancement.

TimelineProgress follows
animation-timeline: scroll()the nearest scroller's scroll position (block axis)
scroll(root inline)the root scroller on the inline axis; also nearest, self
animation-timeline: view()the element's own visibility within its scroller
view(block 20% 10%)the same, with the viewport inset at the start and end
scroll-timeline: --gallery xa named scroll timeline on a scroller
view-timeline: --carda named view timeline on the subject
timeline-scope: --cardlets an ancestor expose a named timeline to other elements
animation-rangeCovers
coverfrom the first pixel entering to the last pixel leaving (the default)
containwhile fully inside (or fully covering) the scrollport
entry / exitwhile crossing the start / end edge
entry 0% entry 100%fade in as it enters
entry 25% cover 50%mix range names
@keyframes grow { from { scale: 0 1; } }
 
.progress {
  position: fixed;
  inset: 0 0 auto;
  block-size: 4px;
  transform-origin: left;
  background: var(--brand);
  animation: grow linear both;   /* shorthand first */
  animation-timeline: scroll(root);
}

Declare animation-timeline after the animation shorthand, because the shorthand resets it. Use linear easing and both fill, and give the animation a duration only as a fallback. The JS equivalents are new ScrollTimeline() and new ViewTimeline().

Inside a scroll box, scroll() follows that box. The bar stays empty where scroll-driven animations aren't supported.

scroll-timeline.html
<style>
  @keyframes grow { from { scale: 0 1; } to { scale: 1; } }
 
  .box { height: 146px; overflow-y: auto;
    border: 1px solid var(--muted); border-radius: 4px; }
  .bar { position: sticky; top: 0; height: 6px;
    background: var(--graph-0); transform-origin: left;
    scale: 0 1; }
  @supports (animation-timeline: scroll()) {
    .bar { animation: grow linear both;
      animation-timeline: scroll(); }
  }
  .text { height: 520px; padding: 12px; }
</style>
<div class="box">
  <div class="bar"></div>
  <div class="text">Scroll this box: the bar tracks it.</div>
</div>
Result

View transitions

The browser snapshots the old state, you update the DOM, and it animates between the snapshots with pseudo-elements you can style.

KindTriggerSupport (MDN)
Same-document (SPA)document.startViewTransition(update)Baseline 2025
Transition typesstartViewTransition({ update, types })Chrome 125, Safari 18.2, Firefox 147
view-transition-classshare one animation between many namesBaseline 2025
Cross-document (MPA)@view-transition { navigation: auto; } on both pages, same originChromium 126+, Safari 18.2+
Element-scopedel.startViewTransition()Chromium only
::view-transition
└─ ::view-transition-group(name)       size and position morph
   └─ ::view-transition-image-pair(name)
      ├─ ::view-transition-old(name)   snapshot, fades out
      └─ ::view-transition-new(name)   live, fades in
CSSDoes
view-transition-name: herothis element gets its own group; names must be unique per page
view-transition-name: nonestays part of the root snapshot
view-transition-class: cardtarget many named groups with ::view-transition-group(.card)
::view-transition-group(*)every group
::view-transition-old(root)the whole-page crossfade
:active-view-transition-type(forward)style by transition type
.card { view-transition-name: var(--vt, none); }
::view-transition-group(*) {
  animation-duration: 300ms;
  animation-timing-function: cubic-bezier(0.2, 0, 0, 1);
}
::view-transition { pointer-events: none; }
 
/* cross-document */
@view-transition { navigation: auto; types: slide; }
ViewTransition memberUse
updateCallbackDoneresolves when your DOM update has finished
readypseudo-elements exist: run custom WAAPI animations on them now
finishedthe animation is over and the new state is live
skipTransition()jump to the end
typesa live Set of the active types

In React 19 and the Next.js App Router, use import { ViewTransition } from "react": it sets the names and runs transitions for you (see the recipe).

Web Animations API

The JS engine behind CSS animations: el.animate(keyframes, options) returns an Animation you can pause, reverse, seek and await. Use it for values computed at runtime (FLIP deltas, pointer positions) and for sequencing. Details and more patterns are in Web Animations.

declare const el: HTMLElement;
 
const anim = el.animate(
  [{ opacity: 0, translate: "0 8px" }, { opacity: 1 }],
  { duration: 200, easing: "ease-out", fill: "backwards" },
);
await anim.finished;
anim.reverse();                     // play it backwards
el.getAnimations().forEach((a) => a.cancel());

JS animation libraries

LibraryPick it for
CSShover, entry and exit, loaders, scroll effects: no JS
Web Animations APIimperative control without a dependency
Motion (motion, v13)React: exit animations, layout and shared-element animations, gestures, real springs
GSAP (v3.15)long timelines, scroll storytelling, SVG morphing, text splitting; any framework

Motion for React

The library formerly called Framer Motion. Install motion and import from motion/react.

"use client";
import { AnimatePresence, motion } from "motion/react";
 
type ToastProps = { open: boolean; text: string };
 
export function Toast(props: ToastProps) {
  return (
    <AnimatePresence>
      {props.open && (
        <motion.div
          key="toast"
          role="status"
          initial={{ opacity: 0, y: 16 }}
          animate={{ opacity: 1, y: 0 }}
          exit={{ opacity: 0, y: 8 }}
          transition={{ type: "spring", bounce: 0.2,
            visualDuration: 0.3 }}
        >
          {props.text}
        </motion.div>
      )}
    </AnimatePresence>
  );
}
Motion APIDoes
initial, animate, exitthe states; exit needs an AnimatePresence parent
layoutanimates any layout change with FLIP
layoutIdshared element: the same id morphs between components
whileHover, whileTap, whileInView, draggestures
variants + staggerChildrenorchestrated lists
useScroll, useTransform, useSpringscroll-linked values
<MotionConfig reducedMotion="user">follows the OS setting app-wide
motion/react-m + LazyMotiona smaller bundle

Motion 13 dropped the optional @emotion/is-prop-valid dependency; that only matters if you wrap motion components with styled-components or Emotion.

GSAP

GSAP has been free for all uses, including every former "Club" plugin (SplitText, MorphSVG, ScrollTrigger and so on), since version 3.13.

import { gsap } from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
 
gsap.registerPlugin(ScrollTrigger);
 
const mm = gsap.matchMedia();
mm.add("(prefers-reduced-motion: no-preference)", () => {
  gsap.from(".feature", {
    y: 40,
    opacity: 0,
    stagger: 0.08,
    ease: "power2.out",
    scrollTrigger: {
      trigger: ".features",
      start: "top 75%",
    },
  });
});

In React, use useGSAP(() => { ... }, { scope: ref }) from @gsap/react; it reverts the animations on unmount.

SVG animation

TechniqueUse
CSS transform on SVG elementsset transform-box: fill-box and transform-origin: center to rotate around the shape
stroke-dasharray + stroke-dashoffsetline drawing; pathLength="1" makes the math easy
CSS d: path("...")morph between paths with matching commands (Chromium, Firefox)
SMIL <animate>works inside <img>-loaded SVGs, where CSS can't reach
GSAP MorphSVGmorph between paths that don't match
Motion pathLength<motion.path animate={{ pathLength: 1 }} />
<svg viewBox="0 0 24 24" class="check">
  <path pathLength="1" d="M4 12l5 5L20 6" />
</svg>
.check path {
  fill: none;
  stroke: currentColor;
  stroke-width: 2;
  stroke-dasharray: 1;
  stroke-dashoffset: 1;
  transition: stroke-dashoffset 300ms ease-out;
}
.done .check path { stroke-dashoffset: 0; }

Accessibility

RuleDetail
Respect prefers-reduced-motion: reduceremove parallax, large slides, zooms, autoplay and scroll-jacking
Reduce, don't necessarily removekeep short opacity fades and color changes: they carry meaning without movement
No flashingnothing that flashes more than 3 times per second (WCAG 2.3.1)
Pause controlsanything that moves for more than 5 seconds needs pause, stop or hide (WCAG 2.2.2)
Interaction-triggered motionshould be possible to disable (WCAG 2.3.3, AAA)
Don't hide content behind animationcontent must be readable without JS or with animations off
Focusanimated dialogs still move focus at once; don't wait for the animation
Live regionsa toast that animates still needs role="status"
/* opt in: motion only when reduce isn't requested */
@media (prefers-reduced-motion: no-preference) {
  .hero { animation: float 6s ease-in-out infinite; }
  html { scroll-behavior: smooth; }
}
const query = "(prefers-reduced-motion: reduce)";
const reduce = matchMedia(query);
export const prefersReduced = (): boolean => reduce.matches;
reduce.addEventListener("change", (e) => {
  document.documentElement.dataset.motion = e.matches
    ? "reduce"
    : "full";
});

Recipes

Fade and slide in on mount

Entry animation for any element when it's inserted or unhidden, with no JS.

.enter {
  transition:
    opacity 250ms cubic-bezier(0.2, 0, 0, 1),
    translate 250ms cubic-bezier(0.2, 0, 0, 1);
 
  @starting-style {
    opacity: 0;
    translate: 0 0.75rem;
  }
}
/* stagger: set --i inline, or use sibling-index() */
.list > .enter {
  transition-delay: calc(var(--i, 0) * 40ms);
}

The demo inserts the items on hover by switching them from display: none:

enter.html
<style>
  .enter {
    transition:
      opacity 250ms cubic-bezier(0.2, 0, 0, 1),
      translate 250ms cubic-bezier(0.2, 0, 0, 1);
    @starting-style { opacity: 0; translate: 0 0.75rem; }
  }
  .list > .enter {
    transition-delay: calc(var(--i, 0) * 40ms);
  }
 
  .list { height: 146px; padding: 8px; border-radius: 6px;
    border: 1px dashed var(--muted); }
  .list > .enter { display: none; margin-top: 6px;
    padding: 4px 8px; border-radius: 4px;
    background: var(--chip); }
  .list:hover > .enter { display: block; }
</style>
<div class="list">Hover to insert the items
  <div class="enter" style="--i: 0">One</div>
  <div class="enter" style="--i: 1">Two</div>
  <div class="enter" style="--i: 2">Three</div>
</div>
Result

Animate dialog open and close

A modal that fades and scales in and out, backdrop included.

dialog {
  opacity: 0;
  scale: 0.96;
  transition:
    opacity 200ms ease-out,
    scale 200ms ease-out,
    overlay 200ms allow-discrete,
    display 200ms allow-discrete;
}
dialog[open] { opacity: 1; scale: 1; }
@starting-style {
  dialog[open] { opacity: 0; scale: 0.96; }
}
 
dialog::backdrop {
  background: oklch(0% 0 0 / 0);
  transition:
    background 200ms,
    overlay 200ms allow-discrete,
    display 200ms allow-discrete;
}
dialog[open]::backdrop { background: oklch(0% 0 0 / 40%); }
@starting-style {
  dialog[open]::backdrop { background: oklch(0% 0 0 / 0); }
}

Scroll progress bar

A reading-progress bar tied to page scroll, off the main thread.

@keyframes progress {
  from { scale: 0 1; }
  to { scale: 1 1; }
}
 
.read-progress {
  position: fixed;
  inset: 0 0 auto;
  z-index: 10;
  block-size: 3px;
  background: var(--brand);
  transform-origin: 0 50%;
  animation: progress linear both;
  animation-timeline: scroll(root block);
}
@supports not (animation-timeline: scroll()) {
  .read-progress { display: none; }
}

Reveal on scroll with view()

Elements fade up as they enter the viewport; browsers without support show them as normal.

@keyframes reveal {
  from { opacity: 0; translate: 0 2rem; }
}
 
@supports (animation-timeline: view()) {
  @media (prefers-reduced-motion: no-preference) {
    .reveal {
      animation: reveal linear both;
      animation-timeline: view();
      animation-range: entry 0% cover 30%;
    }
  }
}

Page transitions (Next.js and SPA)

Next.js App Router: wrap each page's content (not the layout) and trigger with Link transitionTypes.

app/posts/page.tsx
import { ViewTransition } from "react";
import Link from "next/link";
 
const slide = {
  "nav-forward": "slide-left",
  "nav-back": "slide-right",
  default: "none",
};
 
export default function Page() {
  return (
    <ViewTransition
      enter={slide}
      exit={slide}
      default="none"
    >
      <Link
        href="/posts/1"
        transitionTypes={["nav-forward"]}
      >
        First post
      </Link>
    </ViewTransition>
  );
}

Anywhere else, call the browser API directly (it falls back when unsupported):

export async function withTransition(
  update: () => void | Promise<void>,
  types: string[] = [],
): Promise<void> {
  const q = "(prefers-reduced-motion: reduce)";
  const unsupported = !("startViewTransition" in document);
  if (unsupported || matchMedia(q).matches) {
    await update();
    return;
  }
  const vt = document.startViewTransition({ update, types });
  await vt.finished;
}

Spring-like easing with linear()

A bouncy settle for sheets and toggles; keep a plain fallback for old browsers.

:root {
  --spring: cubic-bezier(0.34, 1.56, 0.64, 1);
}
@supports (transition-timing-function: linear(0, 1)) {
  :root {
    --spring: linear(0, 0.145, 0.436, 0.721, 0.93, 1.048,
      1.092, 1.089, 1.065, 1.036, 1.013, 0.999, 0.992,
      0.991, 0.993, 0.996, 1);
  }
}
.switch-thumb { transition: translate 700ms var(--spring); }

Hover the frame: the top thumb uses the spring, the bottom one plain ease-out.

spring.html
<style>
  :root {
    --spring: linear(0, 0.145, 0.436, 0.721, 0.93, 1.048,
      1.092, 1.089, 1.065, 1.036, 1.013, 0.999, 0.992,
      0.991, 0.993, 0.996, 1);
  }
  .track { width: 240px; height: 28px; padding: 2px;
    margin-bottom: 10px; border-radius: 14px;
    background: var(--chip); }
  .thumb { width: 24px; height: 24px; border-radius: 50%;
    background: var(--graph-3);
    transition: translate 700ms var(--spring); }
  .plain .thumb { background: var(--muted);
    transition-timing-function: ease-out; }
  body:hover .thumb { translate: 212px 0; }
</style>
<div class="track"><div class="thumb"></div></div>
<div class="track plain"><div class="thumb"></div></div>
Result

Respect reduced motion globally

A safety net that neutralizes all CSS motion, view transitions included.

@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;
  }
  ::view-transition-group(*),
  ::view-transition-old(*),
  ::view-transition-new(*) {
    animation: none !important;
  }
}

0.01ms rather than none keeps animationend and transitionend firing for code that waits on them.

FLIP list reorder

Animate items to their new positions after a sort or filter, using WAAPI.

export function flip(
  items: HTMLElement[],
  mutate: () => void,
): void {
  const first = new Map(
    items.map((el) => [el, el.getBoundingClientRect()]),
  );
  mutate();                                     // Last
  for (const el of items) {
    const a = first.get(el);
    if (!a || !el.isConnected) continue;
    const b = el.getBoundingClientRect();
    const dx = a.left - b.left;                 // Invert
    const dy = a.top - b.top;
    if (dx === 0 && dy === 0) continue;
    el.animate(                                 // Play
      [
        { translate: `${dx}px ${dy}px` },
        { translate: "0 0" },
      ],
      { duration: 300, easing: "cubic-bezier(.2,0,0,1)" },
    );
  }
}

References