web-animation-view-transitions · git:20260906.d80c3e7 · 2026-09-06 · sha256 2f8fa47c48d89ef9

web-animation-view-transitions git:20260906.d80c3e7A

Immutable. This exact content is served forever at /api/v1/blob/2f8fa47c48d89ef9.

---
name: web-animation-view-transitions
description: View Transitions API patterns - same-document transitions, cross-document navigation, shared element animations, pseudo-element styling, transition types, reduced-motion handling
---

# View Transitions API Patterns

> **Quick Guide:** The browser screenshots the old state, holds the new one live, and animates
> between them as a tree of pseudo-elements. `document.startViewTransition()` drives a same-document
> change; `@view-transition { navigation: auto }` on both pages drives a cross-document one.
> `view-transition-name` pulls an element out of the root snapshot so it can travel on its own, and
> the whole animation is customised in CSS rather than in script.

**Detailed Resources:**

- [examples/core.md](examples/core.md) — feature detection, state transitions, the three promises, skipping, CSS overrides
- [examples/spa.md](examples/spa.md) — theme reveal, form steps, tab panels, accordions, list reordering, reduced motion
- [examples/shared-elements.md](examples/shared-elements.md) — hero animations, multi-element cards, cross-document pairs, modals
- [reference.md](reference.md) — naming rules, pseudo-element tree, browser support, API tables

---

## Which path applies

- **Client routing or in-page state, with the DOM updated by script** — `startViewTransition()` takes
  the update as a callback and captures around it; follow [examples/spa.md](examples/spa.md).
- **Server-rendered pages navigating to each other** — no script at all: both documents opt in with
  `@view-transition { navigation: auto }`, and names are set through the `pageswap` and `pagereveal`
  events; follow [examples/shared-elements.md](examples/shared-elements.md).
- **One element has to travel between the two states rather than cross-fade with everything else** —
  it needs a `view-transition-name` matched on both sides; follow
  [examples/shared-elements.md](examples/shared-elements.md).

---

<critical_requirements>

## Before writing View Transitions code

**Guard `startViewTransition` behind a support check, and run the update either way.** The call is
absent rather than inert where the API is unimplemented, so an unguarded call throws and the DOM
change never happens at all.

**Give each participating element a name no other visible element carries.** The name identifies one
snapshot; two visible elements claiming the same one abort the whole transition, not just their own
part of it.

**Clear a dynamically assigned `view-transition-name` once `transition.finished` resolves.** A name
left on an element collides with the next transition that assigns it, which is why the failure shows
up on the second navigation rather than the first.

**Give every customised transition a `prefers-reduced-motion` branch.** A page-level transition moves
the entire viewport, which is the class of motion the preference exists for; collapse the duration or
skip the transition and apply the update directly.

</critical_requirements>

---

**Auto-detection:** startViewTransition, view-transition-name, view-transition-class,
@view-transition, ::view-transition-old, ::view-transition-new, ::view-transition-group,
::view-transition-image-pair, :active-view-transition-type, pageswap, pagereveal, updateCallbackDone,
skipTransition, match-element

**Applies to:**

- State changes large enough that the whole view, or a large region of it, is replaced
- Page-to-page navigation in server-rendered sites
- Hero animations where one element persists across two views
- Direction-aware navigation, where forward and back need opposite motion
- Reordering, where elements move rather than change

**Handled elsewhere:**

- Hover, focus and pressed feedback on a single control — nothing is being replaced, so there is no
  before-and-after pair to capture
- Motion whose velocity carries across an interruption, such as a spring picked up mid-gesture
- Frame-level timeline authoring, where the deliverable is a scrubbable timeline

---

<philosophy>

A view transition is a screenshot and a live view, animated against each other. Everything follows
from that: the old side is inert pixels, so anything moving inside it freezes; naming an element
lifts it into its own snapshot pair with its own animation; and the whole tree is styled with CSS
because it is a tree of pseudo-elements, not a script-driven animation.

The DOM update itself stays ordinary. `startViewTransition` wraps a change that would have happened
anyway, which is why the fallback for an unsupported browser is simply making the change.

</philosophy>

---

<decision_framework>

## Which snapshot the element belongs to

```
Does an element exist on both sides and represent the same thing?
├─ YES -> give it one view-transition-name on both sides; it gets its own group and travels
└─ NO  -> leave it in the root snapshot; it cross-fades with everything else around it
```

Each named element adds a group, an image pair and two snapshots to the tree, so naming everything
costs more than it buys — name what a reader would follow with their eyes.

## Default or custom animation

```
Cross-fade at a different duration    -> set animation-duration on ::view-transition-old/new(root)
Slide, scale, wipe                    -> keyframes on the old and new pseudo-elements
Direction-dependent motion            -> a transition type, selected with :active-view-transition-type()
Geometry computed at the moment of the transition (a reveal from a click point)
                                      -> await transition.ready, then animate the pseudo-element
```

## Where the name is set

CSS is right for an element that is unique on the page — a header, a hero, a single panel. Script is
right for anything appearing more than once, because a static rule over a list gives every row the
same name and breaks the transition; assign per element before the transition and clear after it, or
use `view-transition-name: match-element` where support allows.

</decision_framework>

---

<patterns>

## Core patterns

### Pattern 1: Feature Detection with Fallback

```typescript
const SUPPORTS_VIEW_TRANSITIONS =
  typeof document !== "undefined" && "startViewTransition" in document;

function updateWithTransition(updateFn: () => void | Promise<void>): void {
  if (!SUPPORTS_VIEW_TRANSITIONS) {
    updateFn();
    return;
  }
  document.startViewTransition(() => updateFn());
}
```

The `typeof document` guard matters during server rendering, where there is no `document` to probe.

Full code: [examples/core.md](examples/core.md)

---

### Pattern 2: Same-Document Transitions

The callback performs the DOM update; the browser captures before calling it and again after it
resolves.

```typescript
const transition = document.startViewTransition(async () => {
  await updateFn();
});

// Options form: classify the transition for CSS to select on
const transition = document.startViewTransition({
  update: () => updateDOM(),
  types: ["slide-forward"],
});

await transition.finished;
```

| Promise                         | Resolves when                                        |
| ------------------------------- | ---------------------------------------------------- |
| `transition.updateCallbackDone` | The DOM update callback has finished                 |
| `transition.ready`              | The pseudo-element tree exists and animation is next |
| `transition.finished`           | The animation is over and the new view is live       |

An `async` callback holds the page frozen until it resolves, so anything slow inside it is a visible
stall — update to a loading state rather than awaiting a network round trip.

Full code: [examples/core.md](examples/core.md)

---

### Pattern 3: Cross-Document Transitions

```css
/* on both the source and the destination document */
@view-transition {
  navigation: auto;
}
```

No script and no router involvement; it covers push, replace and traverse navigations within one
origin. Only one side opting in yields no transition at all.

Full code: [examples/shared-elements.md](examples/shared-elements.md)

---

### Pattern 4: Shared Element Transitions

Matching names on either side of the change make one element travel instead of cross-fading.

```css
.product-thumbnail {
  view-transition-name: product-hero;
}
.product-image {
  view-transition-name: product-hero;
}

::view-transition-group(product-hero) {
  animation-duration: var(--hero-duration);
  animation-timing-function: var(--hero-easing);
}
```

The group animates position and size; the image pair cross-fades the two snapshots inside it. Where
the two sides differ in aspect ratio, `object-fit` on the old and new pseudo-elements stops the
snapshot squashing.

Full code: [examples/shared-elements.md](examples/shared-elements.md)

---

### Pattern 5: Custom CSS Animations

The default cross-fade is a UA stylesheet animation on the old and new pseudo-elements; overriding it
is ordinary CSS.

```css
::view-transition-old(root) {
  animation: slide-out-left var(--transition-duration) var(--transition-easing);
}
::view-transition-new(root) {
  animation: slide-in-right var(--transition-duration) var(--transition-easing);
}
```

Full code: [examples/core.md](examples/core.md)

---

### Pattern 6: Direction-Aware Transitions

A transition type is a label the CSS can select on, so forward and back share one set of keyframes
and differ only in which pair is applied.

```css
html:active-view-transition-type(forwards) {
  &::view-transition-old(content) {
    animation-name: slide-out-left;
  }
  &::view-transition-new(content) {
    animation-name: slide-in-right;
  }
}

html:active-view-transition-type(backwards) {
  &::view-transition-old(content) {
    animation-name: slide-out-right;
  }
  &::view-transition-new(content) {
    animation-name: slide-in-left;
  }
}
```

```typescript
document.startViewTransition({
  update: () => navigateForward(),
  types: ["forwards"],
});
```

`transition.types` is a mutable set on an existing transition, for the case where the direction is
only known after the update has started.

Full code: [examples/spa.md](examples/spa.md)

---

### Pattern 7: Reduced Motion

```css
@media (prefers-reduced-motion: reduce) {
  ::view-transition-group(*),
  ::view-transition-old(*),
  ::view-transition-new(*) {
    animation-duration: 0.01ms !important;
  }
}
```

```typescript
const REDUCED_MOTION_QUERY = "(prefers-reduced-motion: reduce)";

function shouldEnableTransitions(): boolean {
  if (window.matchMedia(REDUCED_MOTION_QUERY).matches) return false;
  return "startViewTransition" in document;
}
```

The CSS form collapses the animation while keeping the transition machinery, which is enough for a
cross-fade. The script form skips the transition entirely, which is the honest answer for a
full-viewport slide or a reveal. Between them sits a third: cancel the animation on
`::view-transition-group(*)`, which is what carries the size and position change, and leave the old
and new pseudo-elements cross-fading. That drops the travel without dropping the feedback.

Full code: [examples/spa.md](examples/spa.md)

---

### Pattern 8: Geometry Computed at Transition Time

Where the animation depends on something known only at the moment of the click, await
`transition.ready` and animate the pseudo-element directly.

```typescript
const transition = document.startViewTransition(updateFn);
await transition.ready;

document.documentElement.animate(
  {
    clipPath: [`circle(0 at ${x}px ${y}px)`, `circle(${r}px at ${x}px ${y}px)`],
  },
  {
    duration: REVEAL_DURATION_MS,
    easing: REVEAL_EASING,
    pseudoElement: "::view-transition-new(root)",
  },
);
```

The animation targets the document element with a `pseudoElement` option, because the pseudo-element
has no node of its own to address.

Full code: [examples/spa.md](examples/spa.md)

</patterns>

---

<red_flags>

## Red flags

**Breaks at runtime:**

- `document.startViewTransition(...)` with no support check — throws where the API is absent, so the
  DOM update is lost along with the animation — branch on `"startViewTransition" in document`
- Two visible elements carrying the same `view-transition-name` — the whole transition is abandoned,
  including the parts that were correct — assign per element from script, or use `match-element`
- A `view-transition-name` set in CSS on a list selector — every row claims the same name — set it
  from script before the transition and clear it after
- A name left assigned after `transition.finished` — collides on the next transition, so the failure
  appears one navigation later than the cause — clear it in the `finished` handler
- `@view-transition` on only one of the two documents — cross-document transitions need both sides to
  opt in — add the at-rule to the destination as well
- Animating a pseudo-element before `transition.ready` resolves — the tree does not exist yet and the
  animation silently targets nothing — await the promise first
- No `prefers-reduced-motion` branch — a full-viewport slide reaches users who asked for none — add
  the media query, or skip the transition in script
- `<meta name="view-transition" content="same-origin">` — obsolete and ignored — use the
  `@view-transition` at-rule

**Surprising behaviour:**

- The old side is a screenshot, so video, GIFs and running animations freeze in it; the new side is
  live and keeps playing
- Names are document-global, so the same name on two unrelated sections conflicts even when they are
  far apart
- The page is inert for the duration of the transition, which puts a long animation directly into
  interaction latency — under 300ms for anything on a navigation path
- Cross-document transitions require same-origin navigation
- `match-element` needs Chrome 137+ or Safari 18.4+ and is unavailable in Firefox
- A `pagereveal` handler registered late misses the event — put it in `<head>`, or mark the script
  `blocking="render"`
- `auto`, `inherit`, `none` and `unset` are CSS-wide keywords rather than custom identifiers, so they
  cannot be used as names

</red_flags>