Getting started

Every component is one React file plus a little CSS. Copy both into your project; there are no packages to install.

Requirements

  • React 19.3+ for <ViewTransition>, and Tailwind CSS v4.
  • Foundational tailwind theme variables for colors, like --color-card. shadcn projects already have them.
  • A browser with view transition types and classes: current Chrome, Edge, Safari and Firefox. Elsewhere everything works, without the animation.

Installation

  1. 1Add the base CSS. Import morph.css once, after Tailwind. No shadcn? Add the theme variables too.
  2. 2Copy a component. Its Component tab goes in components/.
  3. 3Use it. Start from its Usage tab.

Using an AI agent? Try the Morph UI skill.

How it works

1

Wrap. Put <ViewTransition> around what should animate, with a class for each kind of change.

2

Update. Change state inside startTransition. addTransitionType tags direction, like next or back.

3

Animate. The browser snapshots before and after. The motion lives in CSS, keyed by those classes.

React names the snapshots and schedules the browser transition, so components never collide. Anything that must stay clickable mid-animation sits outside the boundary.

example.tsx

import { addTransitionType, startTransition, ViewTransition } from "react"

// Mark what animates, and which class each kind of change gets
<ViewTransition update={{ "step-next": "vt-slide vt-forward", "step-back": "vt-slide vt-back", default: "none" }}>
  <div>{step.title}</div>
</ViewTransition>

// Animate a change by making it a transition
document.activeViewTransition?.skipTransition()
startTransition(() => {
  addTransitionType("step-next")
  setStep((s) => s + 1)
})

Base CSS

morph.css holds every component's motion. Add it once; components need nothing else.

globals.css

@import "tailwindcss";
@import "./morph.css";

Its only rule on your root, view-transition-name: none, keeps the page clickable while things animate. Need just one component? Use its CSS tab instead.

morph.css

/* ─────────────────────────────────────────────────────────────
   Morph UI
   A small set of view transition classes. Copy this file next to
   globals.css and import it after Tailwind:  @import "./morph.css";

   Give the classes to React's <ViewTransition> and combine them:
     <ViewTransition update="vt-move vt-shell">
     <ViewTransition share="vt-morph vt-text">
     update={{ "nav-forward": "vt-push vt-clip vt-forward", default: "none" }}

   Use the CSS tab on a component page for only its required rules.
   Tune everything through the settings below.
   ───────────────────────────────────────────────────────────── */

/* ── Settings ─────────────────────────────────────────────────

   Every setting is written where it's used, with its default:
   var(--vt-move, 420ms). Nothing is declared on your :root, so set
   any of them anywhere in your CSS, in any order:

     :root { --vt-move: 300ms; }

   --vt-exit             150ms   old content leaving
   --vt-enter            210ms   new content arriving
   --vt-enter-delay       90ms   when new content starts, once the old is mostly gone
   --vt-move             420ms   moves and resizes
   --vt-slow             480ms   big shape changes (vt-slow)
   --vt-stagger           35ms   step between vt-delay-* classes
   --vt-ease             cubic-bezier(0.32, 0.72, 0, 1)
   --vt-ease-out         cubic-bezier(0.33, 1, 0.68, 1)   crossfades: starts at once, spreads the change out
   --vt-bounce           a soft overshoot for vt-pop and vt-spring
   --vt-radius           calc(var(--radius) * 1.4)   card corners while animating
   --vt-surface          var(--card)                 card surface while animating
   --vt-ring             10% of --foreground         card ring while animating
   --vt-inverse          var(--foreground)           vt-inverse pill: dark in light mode, light in dark mode
   --vt-inverse-radius   32px
   --vt-swipe-travel     calc(100% + 1.5rem)         one width plus a gap
   --vt-swipe-duration   300ms
   --vt-swipe-ease       cubic-bezier(0.25, 1, 0.5, 1)   fast start, lands without creeping
   --vt-push-duration    300ms
   --vt-push-ease        cubic-bezier(0.3, 0.7, 0.1, 1)  iOS-like push and pop
   ───────────────────────────────────────────────────────────── */

:root {
  view-transition-name: none;
}

::view-transition {
  pointer-events: none;
}

/* ── Motion ──────────────────────────────────────────────────── */

::view-transition-group(.vt-move),
::view-transition-group(.vt-morph),
::view-transition-group(.vt-fade),
::view-transition-group(.vt-roll) {
  animation-duration: var(--vt-move, 420ms);
  animation-timing-function: var(--vt-ease, cubic-bezier(0.32, 0.72, 0, 1));
}
::view-transition-image-pair(.vt-morph) {
  animation: var(--vt-move, 420ms) var(--vt-ease, cubic-bezier(0.32, 0.72, 0, 1)) vt-via-blur;
}
::view-transition-old(.vt-fade) {
  animation: var(--vt-exit, 150ms) var(--vt-ease-out, cubic-bezier(0.33, 1, 0.68, 1)) both
    vt-fade-out;
}
::view-transition-new(.vt-fade) {
  animation: var(--vt-enter, 210ms) var(--vt-ease-out, cubic-bezier(0.33, 1, 0.68, 1))
    var(--vt-enter-delay, 90ms) both vt-fade;
}

::view-transition-group(.vt-slow) {
  animation-duration: var(--vt-slow, 480ms);
}

::view-transition-group(.vt-spring) {
  animation-timing-function: var(
    --vt-bounce,
    linear(0, 0.22 8%, 0.6 18%, 0.94 30%, 1.07 40%, 1.05 48%, 1 60%, 0.99 74%, 1)
  );
}

::view-transition-old(.vt-dissolve) {
  animation: var(--vt-enter, 210ms) linear both vt-dissolve-out;
}
::view-transition-new(.vt-dissolve) {
  animation: var(--vt-enter, 210ms) linear both vt-dissolve-in;
}
@keyframes vt-dissolve-out {
  from {
    opacity: 1;
  }
  to {
    opacity: 0;
  }
}
@keyframes vt-dissolve-in {
  from {
    opacity: 0;
  }
  to {
    opacity: 1;
  }
}

::view-transition-old(.vt-reveal) {
  transform-origin: center;
  animation:
    var(--vt-exit, 150ms) linear both vt-dissolve-out,
    var(--vt-slow, 480ms)
      var(
        --vt-bounce,
        linear(0, 0.22 8%, 0.6 18%, 0.94 30%, 1.07 40%, 1.05 48%, 1 60%, 0.99 74%, 1)
      )
      both vt-reveal-out;
}
::view-transition-new(.vt-reveal) {
  transform-origin: center;
  animation:
    var(--vt-enter, 210ms) linear both vt-dissolve-in,
    var(--vt-slow, 480ms)
      var(
        --vt-bounce,
        linear(0, 0.22 8%, 0.6 18%, 0.94 30%, 1.07 40%, 1.05 48%, 1 60%, 0.99 74%, 1)
      )
      both vt-reveal-in;
}
@keyframes vt-reveal-in {
  from {
    scale: 0.92;
  }
  to {
    scale: 1;
  }
}
@keyframes vt-reveal-out {
  from {
    scale: 1;
  }
  to {
    scale: 0.92;
  }
}

::view-transition-group(.vt-blur) {
  animation: none;
}
::view-transition-old(.vt-blur),
::view-transition-new(.vt-blur) {
  width: auto;
  height: auto;
  left: 50%;
  translate: -50% 0;
}
::view-transition-old(.vt-blur) {
  animation: 250ms var(--vt-ease-out, cubic-bezier(0.33, 1, 0.68, 1)) both vt-blur-out;
}
::view-transition-new(.vt-blur) {
  animation: 250ms var(--vt-ease-out, cubic-bezier(0.33, 1, 0.68, 1)) both vt-blur-in;
}
::view-transition-image-pair(.vt-blur),
::view-transition-image-pair(.vt-expand) {
  isolation: isolate;
}
::view-transition-old(.vt-blur),
::view-transition-new(.vt-blur),
::view-transition-old(.vt-expand),
::view-transition-new(.vt-expand) {
  mix-blend-mode: plus-lighter;
}
::view-transition-old(.vt-blur):only-child,
::view-transition-new(.vt-blur):only-child,
::view-transition-old(.vt-expand):only-child,
::view-transition-new(.vt-expand):only-child {
  mix-blend-mode: normal;
}
::view-transition-old(.vt-blur):only-child {
  animation: 250ms var(--vt-ease-out, cubic-bezier(0.33, 1, 0.68, 1)) both vt-blur-leave;
}
@keyframes vt-blur-leave {
  40% {
    opacity: 1;
  }
  to {
    opacity: 0;
    filter: blur(4px);
  }
}

@keyframes vt-fade {
  from {
    opacity: 0;
    filter: blur(3px);
  }
}
@keyframes vt-fade-out {
  to {
    opacity: 0;
    filter: blur(3px);
  }
}
@keyframes vt-via-blur {
  30% {
    filter: blur(3px);
  }
}
@keyframes vt-blur-out {
  to {
    opacity: 0;
    filter: blur(4px);
  }
}
@keyframes vt-blur-in {
  from {
    opacity: 0;
    filter: blur(4px);
  }
}

/* ── Presence: items added to or removed from a list ─────────── */

::view-transition-new(.vt-presence):only-child {
  animation: var(--vt-enter, 210ms) var(--vt-ease-out, cubic-bezier(0.33, 1, 0.68, 1))
    var(--vt-enter-delay, 90ms) both vt-rise;
}
::view-transition-old(.vt-presence):only-child {
  animation: var(--vt-exit, 150ms) var(--vt-ease-out, cubic-bezier(0.33, 1, 0.68, 1)) both
    vt-fade-out;
}

::view-transition-new(.vt-rise) {
  animation: 300ms var(--vt-ease-out, cubic-bezier(0.33, 1, 0.68, 1)) both vt-rise;
}
::view-transition-old(.vt-rise) {
  animation: var(--vt-exit, 150ms) var(--vt-ease-out, cubic-bezier(0.33, 1, 0.68, 1)) both
    vt-fade-out;
}
@keyframes vt-rise {
  from {
    opacity: 0;
    translate: 0 8px;
    filter: blur(4px);
  }
}

::view-transition-group(.vt-pop) {
  animation-duration: 300ms;
  animation-timing-function: var(
    --vt-bounce,
    linear(0, 0.22 8%, 0.6 18%, 0.94 30%, 1.07 40%, 1.05 48%, 1 60%, 0.99 74%, 1)
  );
}
::view-transition-new(.vt-pop):only-child {
  animation: 300ms
    var(--vt-bounce, linear(0, 0.22 8%, 0.6 18%, 0.94 30%, 1.07 40%, 1.05 48%, 1 60%, 0.99 74%, 1))
    both vt-pop;
}
::view-transition-old(.vt-pop):only-child {
  animation: 200ms var(--vt-ease-out, cubic-bezier(0.33, 1, 0.68, 1)) both vt-pop-out;
}

@keyframes vt-pop {
  from {
    scale: 0.9;
    opacity: 0;
  }
}
@keyframes vt-pop-out {
  to {
    scale: 0.9;
    opacity: 0;
  }
}

/* ── Sizing: how the snapshots fill a changing box ───────────── */

::view-transition-old(.vt-text),
::view-transition-new(.vt-text) {
  height: 100%;
  width: auto;
  object-fit: none;
  object-position: left top;
}

::view-transition-old(.vt-center),
::view-transition-new(.vt-center) {
  width: 100%;
  height: 100%;
  object-fit: none;
  object-position: center;
}

::view-transition-old(.vt-top),
::view-transition-new(.vt-top) {
  width: 100%;
  height: 100%;
  object-fit: none;
  object-position: top;
  transform-origin: top;
}

::view-transition-image-pair(.vt-cover) {
  overflow: clip;
  border-radius: var(--radius);
}
::view-transition-old(.vt-cover),
::view-transition-new(.vt-cover) {
  height: 100%;
  object-fit: cover;
}

/* ── Surfaces: cards that resize without stretching ──────────── */

::view-transition-group(.vt-shell),
::view-transition-group(.vt-expand) {
  background: var(--vt-surface, var(--card));
  border-radius: var(--vt-radius, calc(var(--radius) * 1.4));
  box-shadow: 0 0 0 1px var(--vt-ring, color-mix(in oklab, var(--foreground) 10%, transparent));
}

::view-transition-old(.vt-shell),
::view-transition-new(.vt-shell) {
  display: none;
}

::view-transition-group(.vt-shell.vt-inverse) {
  background: var(--vt-inverse, var(--foreground));
  border-radius: var(--vt-inverse-radius, 32px);
  box-shadow: none;
}

::view-transition-image-pair(.vt-expand) {
  overflow: clip;
  border-radius: var(--vt-radius, calc(var(--radius) * 1.4));
}
::view-transition-old(.vt-expand),
::view-transition-new(.vt-expand) {
  width: 100%;
  height: 100%;
  object-fit: none;
  object-position: left top;
}
::view-transition-old(.vt-expand) {
  animation: 250ms var(--vt-ease-out, cubic-bezier(0.33, 1, 0.68, 1)) both vt-blur-out;
}
::view-transition-new(.vt-expand) {
  animation: 250ms var(--vt-ease-out, cubic-bezier(0.33, 1, 0.68, 1)) both vt-blur-in;
}

::view-transition-image-pair(.vt-clip) {
  overflow: clip;
  border-radius: var(--vt-radius, calc(var(--radius) * 1.4));
}

::view-transition-group(.vt-clip) {
  overflow: clip;
  border-radius: var(--vt-radius, calc(var(--radius) * 1.4));
}

::view-transition-group(.vt-scroll) {
  overflow: clip;
}

::view-transition-group(.vt-edge-bottom) {
  mask-image: linear-gradient(to bottom, black calc(100% - 3rem), transparent);
}

::view-transition-group(.vt-clip.vt-inverse),
::view-transition-image-pair(.vt-clip.vt-inverse) {
  border-radius: var(--vt-inverse-radius, 32px);
}

/* ── Direction: add vt-forward or vt-back ────────────────────── */

::view-transition-group(.vt-slide) {
  animation-duration: 300ms;
  animation-timing-function: var(--vt-ease, cubic-bezier(0.32, 0.72, 0, 1));
}
::view-transition-old(.vt-slide) {
  animation:
    var(--vt-exit, 150ms) var(--vt-ease-out, cubic-bezier(0.33, 1, 0.68, 1)) both vt-fade-out,
    300ms var(--vt-ease, cubic-bezier(0.32, 0.72, 0, 1)) both vt-slide-out;
}
::view-transition-new(.vt-slide) {
  animation:
    var(--vt-enter, 210ms) var(--vt-ease-out, cubic-bezier(0.33, 1, 0.68, 1))
      var(--vt-enter-delay, 90ms) both vt-fade,
    300ms var(--vt-ease, cubic-bezier(0.32, 0.72, 0, 1)) both vt-slide;
}
::view-transition-old(.vt-slide.vt-forward),
::view-transition-new(.vt-slide.vt-back) {
  --vt-offset: -48px;
}
::view-transition-new(.vt-slide.vt-forward),
::view-transition-old(.vt-slide.vt-back) {
  --vt-offset: 48px;
}

::view-transition-group(.vt-swipe) {
  animation-duration: var(--vt-swipe-duration, 300ms);
  animation-timing-function: var(--vt-swipe-ease, cubic-bezier(0.25, 1, 0.5, 1));
}
::view-transition-image-pair(.vt-swipe) {
  overflow: clip;
  border-radius: var(--vt-radius, calc(var(--radius) * 1.4));
}
::view-transition-old(.vt-swipe.vt-forward) {
  animation: var(--vt-swipe-duration, 300ms) var(--vt-swipe-ease, cubic-bezier(0.25, 1, 0.5, 1))
    both vt-swipe-out-left;
}
::view-transition-new(.vt-swipe.vt-forward) {
  animation: var(--vt-swipe-duration, 300ms) var(--vt-swipe-ease, cubic-bezier(0.25, 1, 0.5, 1))
    both vt-swipe-in-right;
}
::view-transition-old(.vt-swipe.vt-back) {
  animation: var(--vt-swipe-duration, 300ms) var(--vt-swipe-ease, cubic-bezier(0.25, 1, 0.5, 1))
    both vt-swipe-out-right;
}
::view-transition-new(.vt-swipe.vt-back) {
  animation: var(--vt-swipe-duration, 300ms) var(--vt-swipe-ease, cubic-bezier(0.25, 1, 0.5, 1))
    both vt-swipe-in-left;
}

::view-transition-group(.vt-push) {
  animation: none;
}
::view-transition-old(.vt-push.vt-forward) {
  animation: var(--vt-push-duration, 300ms) var(--vt-push-ease, cubic-bezier(0.3, 0.7, 0.1, 1)) both
    vt-push-under-out;
}
::view-transition-new(.vt-push.vt-forward) {
  animation: var(--vt-push-duration, 300ms) var(--vt-push-ease, cubic-bezier(0.3, 0.7, 0.1, 1)) both
    vt-push-over-in;
}
::view-transition-old(.vt-push.vt-back) {
  animation: var(--vt-push-duration, 300ms) var(--vt-push-ease, cubic-bezier(0.3, 0.7, 0.1, 1)) both
    vt-push-over-out;
  z-index: 1;
}
::view-transition-new(.vt-push.vt-back) {
  animation: var(--vt-push-duration, 300ms) var(--vt-push-ease, cubic-bezier(0.3, 0.7, 0.1, 1)) both
    vt-push-under-in;
}

::view-transition-group(.vt-roll) {
  animation-duration: var(--vt-enter, 210ms);
}
::view-transition-old(.vt-roll) {
  animation: var(--vt-exit, 150ms) var(--vt-ease-out, cubic-bezier(0.33, 1, 0.68, 1)) both
    vt-roll-out;
}
::view-transition-new(.vt-roll) {
  animation: var(--vt-enter, 210ms) var(--vt-ease, cubic-bezier(0.32, 0.72, 0, 1)) both vt-roll-in;
}
::view-transition-old(.vt-roll.vt-forward),
::view-transition-new(.vt-roll.vt-back) {
  --vt-offset: -1em;
}
::view-transition-new(.vt-roll.vt-forward),
::view-transition-old(.vt-roll.vt-back) {
  --vt-offset: 1em;
}

@keyframes vt-swipe-out-left {
  from {
    translate: 0 0;
  }
  to {
    translate: calc(var(--vt-swipe-travel, calc(100% + 1.5rem)) * -1) 0;
  }
}
@keyframes vt-swipe-in-right {
  from {
    translate: var(--vt-swipe-travel, calc(100% + 1.5rem)) 0;
  }
  to {
    translate: 0 0;
  }
}
@keyframes vt-swipe-out-right {
  from {
    translate: 0 0;
  }
  to {
    translate: var(--vt-swipe-travel, calc(100% + 1.5rem)) 0;
  }
}
@keyframes vt-swipe-in-left {
  from {
    translate: calc(var(--vt-swipe-travel, calc(100% + 1.5rem)) * -1) 0;
  }
  to {
    translate: 0 0;
  }
}
@keyframes vt-slide {
  from {
    translate: var(--vt-offset) 0;
    filter: blur(6px);
  }
}
@keyframes vt-slide-out {
  to {
    translate: var(--vt-offset) 0;
    filter: blur(6px);
  }
}
@keyframes vt-push-under-out {
  to {
    translate: -30% 0;
  }
}
@keyframes vt-push-under-in {
  from {
    translate: -30% 0;
  }
}
@keyframes vt-push-over-in {
  from {
    translate: 100% 0;
  }
}
@keyframes vt-push-over-out {
  to {
    translate: 100% 0;
  }
}
@keyframes vt-roll-out {
  to {
    translate: 0 var(--vt-offset);
    opacity: 0;
    filter: blur(2px);
  }
}
@keyframes vt-roll-in {
  from {
    translate: 0 var(--vt-offset);
    opacity: 0;
    filter: blur(2px);
  }
}

::view-transition-group(.vt-quick) {
  animation-duration: var(--vt-enter, 210ms);
}
::view-transition-old(.vt-quick),
::view-transition-new(.vt-quick) {
  animation-duration: var(--vt-enter, 210ms);
  animation-delay: 0s;
}

/* ── Stagger: vt-delay-1 … vt-delay-7 ────────────────────────── */

::view-transition-old(.vt-delay-1),
::view-transition-new(.vt-delay-1) {
  animation-delay: calc(var(--vt-stagger, 35ms) * 1);
}
::view-transition-old(.vt-delay-2),
::view-transition-new(.vt-delay-2) {
  animation-delay: calc(var(--vt-stagger, 35ms) * 2);
}
::view-transition-old(.vt-delay-3),
::view-transition-new(.vt-delay-3) {
  animation-delay: calc(var(--vt-stagger, 35ms) * 3);
}
::view-transition-old(.vt-delay-4),
::view-transition-new(.vt-delay-4) {
  animation-delay: calc(var(--vt-stagger, 35ms) * 4);
}
::view-transition-old(.vt-delay-5),
::view-transition-new(.vt-delay-5) {
  animation-delay: calc(var(--vt-stagger, 35ms) * 5);
}
::view-transition-old(.vt-delay-6),
::view-transition-new(.vt-delay-6) {
  animation-delay: calc(var(--vt-stagger, 35ms) * 6);
}
::view-transition-old(.vt-delay-7),
::view-transition-new(.vt-delay-7) {
  animation-delay: calc(var(--vt-stagger, 35ms) * 7);
}

/* ── Reduced motion ──────────────────────────────────────────── */

@media (prefers-reduced-motion: reduce) {
  ::view-transition-group(*),
  ::view-transition-image-pair(*) {
    animation-duration: 0s !important;
    animation-delay: 0s !important;
  }
  ::view-transition-old(*) {
    animation: 150ms var(--vt-ease-out, cubic-bezier(0.33, 1, 0.68, 1)) both vt-dissolve-out !important;
  }
  ::view-transition-new(*) {
    animation: 150ms var(--vt-ease-out, cubic-bezier(0.33, 1, 0.68, 1)) both vt-dissolve-in !important;
  }
}

Your theme

Components ship no colors. They use shadcn's variable names, like --card and --muted-foreground, so they pick up your theme and dark mode as they are. Each component page lists the ones it reads.

Using shadcn? Skip this, you already have them. If not, add these to your globals.css once, after @import "tailwindcss", and change the values to your own.

globals.css

/* The theme variables Morph UI reads, with shadcn's neutral defaults.
   Skip this if you use shadcn: you already have them. */
@custom-variant dark (&:where(.dark, .dark *));

:root {
  --radius: 0.625rem;
  --background:           oklch(1 0 0);
  --foreground:           oklch(0.145 0 0);
  --card:                 oklch(1 0 0);
  --card-foreground:      oklch(0.145 0 0);
  --primary:              oklch(0.205 0 0);
  --primary-foreground:   oklch(0.985 0 0);
  --secondary:            oklch(0.97 0 0);
  --secondary-foreground: oklch(0.205 0 0);
  --muted:                oklch(0.97 0 0);
  --muted-foreground:     oklch(0.556 0 0);
  --border:               oklch(0.922 0 0);
  --input:                oklch(0.922 0 0);
  --ring:                 oklch(0.708 0 0);
}

.dark {
  --background:           oklch(0.145 0 0);
  --foreground:           oklch(0.985 0 0);
  --card:                 oklch(0.205 0 0);
  --card-foreground:      oklch(0.985 0 0);
  --primary:              oklch(0.922 0 0);
  --primary-foreground:   oklch(0.205 0 0);
  --secondary:            oklch(0.269 0 0);
  --secondary-foreground: oklch(0.985 0 0);
  --muted:                oklch(0.269 0 0);
  --muted-foreground:     oklch(0.708 0 0);
  --border:               oklch(1 0 0 / 10%);
  --input:                oklch(1 0 0 / 15%);
  --ring:                 oklch(0.556 0 0);
}

@theme inline {
  --radius-md: calc(var(--radius) * 0.8);
  --radius-lg: var(--radius);
  --radius-xl: calc(var(--radius) * 1.4);
  --radius-2xl: calc(var(--radius) * 1.8);
  --color-background: var(--background);
  --color-foreground: var(--foreground);
  --color-card: var(--card);
  --color-card-foreground: var(--card-foreground);
  --color-primary: var(--primary);
  --color-primary-foreground: var(--primary-foreground);
  --color-secondary: var(--secondary);
  --color-secondary-foreground: var(--secondary-foreground);
  --color-muted: var(--muted);
  --color-muted-foreground: var(--muted-foreground);
  --color-border: var(--border);
  --color-input: var(--input);
  --color-ring: var(--ring);
}

Settings

Every timing and curve is written with its default, like var(--vt-move, 420ms), so nothing is added to your :root. Set any of them anywhere in your CSS, in any order. The full list is at the top of morph.css.

globals.css

:root {
  --vt-move: 300ms;
  --vt-ease: cubic-bezier(0.2, 0.8, 0.2, 1);
}

Scrolling mid-animation

While a component animates, the browser draws snapshots pinned to the screen. If the page scrolls during that moment, the animating content seems to drift with the scroll. Add this once, anywhere in your layout, to finish the animation as soon as anything scrolls.

components/skip-on-scroll.tsx

// components/skip-on-scroll.tsx
"use client"

import { useEffect } from "react"

// Animation snapshots are pinned to the screen, not the page. If anything
// scrolls mid-animation, finish it, so content never drifts with the scroll.
export function SkipOnScroll() {
  useEffect(() => {
    const skip = () => document.activeViewTransition?.skipTransition()
    // Capture catches every scroll: the page and any inner scroll area
    addEventListener("scroll", skip, { capture: true, passive: true })
    return () => removeEventListener("scroll", skip, { capture: true })
  }, [])
  return null
}

Classes

Give them to <ViewTransition> and combine them, like update="vt-move vt-shell". Direction classes pair with addTransitionType. Your own components can use them too.

Motion

vt-move
Position and size change, content stays the same
vt-morph
Content changes too; a short blur hides the crossfade
vt-fade
Old fades out, then new fades in
vt-dissolve
Content blends immediately, without blur or delay
vt-reveal
Content scales into a growing, clipped shell from its center
vt-spring
Size and position settle with a soft bounce
vt-blur
A soft, blurred crossfade between two views
vt-slow
A longer move, for big shape changes
vt-quick
A short move or slide, for small UI used all day, like tabs

Lists

vt-presence
Added and removed items fade; pair with vt-move
vt-pop
Items scale in and out, the rest bounce into place
vt-rise
Sections fade up into place; stagger them with vt-delay-*

Sizing

vt-text
Text keeps its natural size, never stretches
vt-center
Content keeps its size, centered in the box
vt-top
Content keeps its size, pinned to the top as the box grows
vt-cover
Images crop to their box, like object-fit: cover

Surfaces

vt-shell
A card surface resizes; its contents animate on their own
vt-expand
A card grows into a new layout, contents crossfade
vt-inverse
Makes vt-shell a pill in the inverse of your theme, like the Dynamic Island
vt-clip
Clips snapshots; add view-transition-group: contain to clip nested groups
vt-scroll
A scroll area: clips nested snapshots at its edges, with square corners
vt-edge-bottom
Keeps a scroll area's faded bottom edge while it animates

Direction

vt-slide
A short slide with a fade, for steps
vt-swipe
Full-width swap, like a carousel
vt-push
An iOS-style stack push and pop
vt-roll
Digits or words roll up or down
vt-forward / vt-back
Which way the four above go
vt-delay-1 … 7
Stagger, in steps of --vt-stagger