---
title: Transitions
description: One command morphs the tree before a change into the tree after it, matched by shared element names, and every frame of it scrubs.
mode: wide
---

`transition(apply, options)` is a command every `LayoutActor` has. It applies a change, and
for `duration` seconds afterwards the renderer draws the actor's view twice: as it was the
frame before the change and as it is now. Elements that carry the same shared name in both
views morph from one box to the other. Elements in only one view fade or slide.

<StandingsScene />

Five teams, then two changes. The first re-ranks the table after a round of points, so every
row moves to its new place. The second swaps the last team for a new one that enters third:
the leaving row slides out to the right, the arriving one slides in from the left, and the
rows between them move down to make room. The scene never says which rows move or where. It
sets the new list.

## The actor

```ts twoslash title="standings.ts"
import { defineShared, LayoutActor, signal } from "@motionactor/core";

export type Team = { name: string; points: number };

export class Standings extends LayoutActor {
  static shared = defineShared({
    row: (name: string) => `row:${name}`,
  });

  teams = signal<Team[]>([]);

  *rank(next: Team[]) {
    yield* this.transition(() => this.teams.set(next), {
      duration: 0.9,
    });
  }
}
```

`defineShared` declares the names the actor's view may tag, each as a function from a key to
a string. `Standings.shared.row("Otters")` builds the name for the Otters row. `<Shared>` accepts only
names built this way, never a bare string, so the view and the actor cannot spell a name two
ways.

`transition` records the cursor as the start, runs `apply` at once and then runs `progress`
from 0 to 1 over `duration`. Any change works: a new list, a reordered
[slot](/docs/runtime/slots), a different view mode, a new size. The command never compares
before and after. It only knows when the change happened.

## The renderer

```tsx twoslash title="standings-view.tsx"
// @jsx: react-jsx
// @filename: standings.ts
import { defineShared, LayoutActor, signal } from "@motionactor/core";
export type Team = { name: string; points: number };
export class Standings extends LayoutActor {
  static shared = defineShared({
    row: (name: string) => `row:${name}`,
  });
  teams = signal<Team[]>([]);
}
// @filename: standings-view.tsx
// ---cut---
import { dom } from "@motionactor/dom/react";
import { Shared, Transition } from "@motionactor/renderers/react";
import { Standings } from "./standings";

const { row } = Standings.shared;

export const standingsView = dom.render(
  Standings,
  ({ actor }) => (
    <Transition actor={actor}>
      {(table) => (
        <ol>
          {table.signals.teams.map((team) => (
            <Shared key={team.name} name={row(team.name)}>
              {team.name} {team.points}
            </Shared>
          ))}
        </ol>
      )}
    </Transition>
  ),
);
```

`<Transition>` calls its child function once when the actor is settled. While a transition
runs it calls it twice, with the actor as it is now and with the actor resolved on the frame
before the change, and stacks the two layers. The view reads its own signals as always and
never asks whether it is mid-transition.

`<Shared name>` measures the element it renders, in both layers, and publishes the boxes to
the actor's `transitionState`. The rest is frame math in the runtime.

## What happens to each element

| Tagged in | Drawn as |
| --- | --- |
| Both layers | One copy on a box moving from the old box to the new one, with its background, border, radius and shadow interpolated, and the two versions of its content crossfading inside |
| The new layer only | Fades in over the first 60% of the transition, or moves in from the `enter` pose |
| The old layer only | Rides the old layer as it fades out over the first 60%, or moves to the `exit` pose |
| Neither | The old version sits on top and fades out over the first 60%, uncovering the new one |

The ghost copy is how a matched row can change its number without a jump: both the 9 and the
15 are drawn inside the moving box, one fading out as the other fades in.

Put the visual styling on the tagged element. A background on the `<Shared>` element morphs,
while a background on a child inside it only crossfades. Use `text(...)` for a name whose
element is a run of text: it scales uniformly and its colour, size and weight interpolate,
so the glyphs never stretch.

```ts twoslash
import { defineShared, text } from "@motionactor/core";

const shared = defineShared({
  card: (id: string) => `card:${id}`,
  title: text((id: string) => `card:${id}:title`),
});
```

## Options

| Option | |
| --- | --- |
| `duration` | Seconds. Required |
| `easing` | The curve applied to the picture. `progress` itself stays linear |
| `stagger` | `{ amount }`, the fraction of the run over which element starts spread, ordered by position. `0.2` to `0.4` reads as a cascade |
| `enter`, `exit` | `fade()`, `slide(direction, distance?)` or `scale(from)` for one-sided elements |
| `choreography` | A program that moves named elements on their own schedule |

Every option belongs to one transition and is cleared when it settles.

### Choreography

`choreography` takes a generator that gets a `tx` handle. `tx.get(name)` returns the named
element's own `progress` through the morph and an `offset` added on top of where the morph
puts it. Elements the program does not name keep the default curve.

```ts twoslash
import { defineShared, LayoutActor, signal } from "@motionactor/core";

class Shelf extends LayoutActor {
  static shared = defineShared({
    book: (title: string) => `book:${title}`,
  });

  titles = signal<string[]>([]);

  *pull(title: string) {
    const rest = this.titles().filter((t) => t !== title);
    yield* this.transition(() => this.titles.set([title, ...rest]), {
      duration: 0.8,
      *choreography(tx) {
        const book = tx.get(Shelf.shared.book(title));
        yield* book.offset.tween({ y: -40 }, 0.2);
        yield* book.progress.tween(1, 0.4);
        yield* book.offset.tween({ y: 0 }, 0.2);
      },
    });
  }
}
```

The pulled book lifts off the shelf, travels to the front and drops back in, while the others
slide along on the default curve. The program also has `tx.wait`, `tx.all`,
`tx.stagger(items, gap, program)` and `tx.children(slot)`, which lists a slot's occupants
before and after the change.

The program runs once, while the scene compiles, and is stored as data. `at(frame)` evaluates
it at that frame like any other recorded value, so a choreographed transition scrubs exactly.
When the program runs longer than `duration`, the transition stretches to fit and the compile
logs a warning.

## Resolved state

The resolved actor carries the transition as `signals.transitionState`:

| Field | |
| --- | --- |
| `start` | The frame the change was applied |
| `progress` | 0 at `start`, 1 when settled, linear |
| `active` | `progress < 1` |
| `layers` | `{ from: { opacity }, to: { opacity } }` for the frame |
| `elements` | Per shared name: the box, style and transform to paint |

Until a renderer has measured both layers, `elements` is empty and the old layer is held
fully visible, so a transition never shows a frame of the new layout in the wrong place. The
measurements are a recorded [channel](/docs/reactivity/channel): scrubbing into the middle of a
transition resolves against the boxes measured for that transition.

`<Transition>` and `<Shared>` ship in the React, Vue and Svelte bindings.
