Skip to content
MotionActor
Esc
↑↓navigate↵open⌘Jpreview

Transitions

One command morphs the tree before a change into the tree after it, matched by shared element names, and every frame of it scrubs.

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.

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

import { function defineShared<const T extends Record<string, SharedDeclaration>>(declarations: T): SharedNames<T>
Declare an actor's shared-element names once, with their kinds. Each entry becomes a builder returning a {@link SharedName } ; the `<Shared>` tag in the renderers accepts only those, so the name set a view publishes is exactly the set the actor declared.
defineShared
, class LayoutActor<TEvents extends ActorEventMap = {}>LayoutActor,
function signal<T>(): RequiredSignal<T> (+1 overload)
module signal
A timeline value on an actor, and an input to every spawn of it. Without an initial value the signal is required: `ctx.spawn` must supply it.
signal
} from "@motionactor/core";
export type
type Team = {
    name: string;
    points: number;
}
Team
= { name: stringname: string; points: numberpoints: number };
export class class StandingsStandings extends class LayoutActor<TEvents extends ActorEventMap = {}>LayoutActor { static
Standings.shared: SharedNames<{
    readonly row: (name: string) => string;
}>
shared
=
defineShared<{
    readonly row: (name: string) => string;
}>(declarations: {
    readonly row: (name: string) => string;
}): SharedNames<{
    readonly row: (name: string) => string;
}>
Declare an actor's shared-element names once, with their kinds. Each entry becomes a builder returning a {@link SharedName } ; the `<Shared>` tag in the renderers accepts only those, so the name set a view publishes is exactly the set the actor declared.
defineShared
({
row: (name: string) => stringrow: (name: stringname: string) => `row:${name: stringname}`, }); Standings.teams: ActorFieldSignal<Team[]>teams = signal<Team[]>(initial: Team[]): ActorFieldSignal<Team[]> (+1 overload)
A timeline value on an actor, and an input to every spawn of it. Without an initial value the signal is required: `ctx.spawn` must supply it.
signal
<
type Team = {
    name: string;
    points: number;
}
Team
[]>([]);
*Standings.rank(next: Team[]): Generator<number, void, unknown>rank(next: Team[]next:
type Team = {
    name: string;
    points: number;
}
Team
[]) {
yield* this.LayoutActor<{}>.transition(apply: () => void, options: TransitionOptions): Generator<Frame, void, unknown>
Apply a mutation and project the change: the tree one frame before the mutation morphs into the tree after it, matched by shared-element name. ```ts yield* this.transition(() => this.view.set("week"), { duration: 0.5 }); yield* this.transition(() => this.items.reorder(next), { duration: 1 }); ```
transition
(() => this.Standings.teams: ActorFieldSignal<Team[]>teams.LiveSignal<Team[]>.set(value: Team[]): void
Set value instantly at current cursor (no yield needed)
set
(next: Team[]next), {
TransitionOptions.duration: number
Authored seconds for the transition.
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, a different view mode, a new size. The command never compares before and after. It only knows when the change happened.

The renderer

import { const dom: StagedBackend<React.FC<ReactBackendHostProps>, "dom", React.ReactNode>
The React DOM backend, and the `dom` binding code names: `dom.render(Box, …)`, `createRenderRuntime({ backends: [dom] })`.
dom
} from "@motionactor/dom/react";
import { const Shared: React.FC<SharedProps>
Tags a shared element by a declared name — the one way a view marks something `<Transition>` should morph. The kind — box or text — comes from the declaration, so a view cannot pick one per tag. Chrome belongs on the tagged element itself: the background, border, radius and shadow are what the runtime measures and the ghost morphs. Chrome on an inner wrapper would only crossfade. ```tsx const { shared } = Calendar; <Shared name={shared.dayShell(key)} className="rounded-xl bg-white/5"> <Shared name={shared.dayNumber(key)}>{day}</Shared> </Shared> ``` A box whose content fills whatever box it is given — a layout container's hosted child — passes its content as a function of the box. Its ghost then lays each view's content out *at* the morphing box every frame instead of scaling a copy laid out at the measured size, so text and media re-lay out rather than stretch, and nothing clips it: ```tsx <Shared name={Grid.shared.child(child.id)} style={placement}> {(box) => renderChild(box ? { ...child, localLayout: { ...child.localLayout, ...box } } : child)} </Shared> ```
Shared
, function Transition<TActor extends TransitioningActor>({ actor, children, className, style, }: TransitionProps<TActor>): React.ReactElementTransition } from "@motionactor/renderers/react";
import { class StandingsStandings } from "./standings"; const { const row: (name: string) => SharedName<"box">row } = class StandingsStandings.
Standings.shared: SharedNames<{
    readonly row: (name: string) => string;
}>
shared
;
export const const standingsView: BackendRendererDefinition<readonly [typeof Standings], "dom">standingsView = const dom: StagedBackend<React.FC<ReactBackendHostProps>, "dom", React.ReactNode>
The React DOM backend, and the `dom` binding code names: `dom.render(Box, …)`, `createRenderRuntime({ backends: [dom] })`.
dom
.render: <typeof Standings>(blueprint: typeof Standings, render: ActorRendererForBlueprints<readonly [typeof Standings], "dom">, options?: ActorRendererRegistrationOptions) => BackendRendererDefinition<readonly [typeof Standings], "dom"> (+1 overload)render(
class StandingsStandings, ({
actor: ResolvedActor & Omit<ResolvedActor, "signals" | "channels"> & {
    compositing: {
        blend: "source-over" | "multiply" | "screen" | "overlay" | "darken" | "lighten" | "color-dodge" | "color-burn" | "hard-light" | "soft-light" | "difference" | "exclusion" | "hue" | "saturation" | "color" | "luminosity" | "plus-lighter";
        isolate: boolean;
        clip: number | null;
    };
    measuredBox: Rect | undefined;
    layout: {
        x: LayoutValue;
        y: LayoutValue;
        width: number | `${number}%` | Readonly<{
            kind: "layout.percent";
            value: number;
        }>;
        height: number | ... 1 more ... | Readonly<...>;
    };
    ... 6 more ...;
    teams: Team[];
} & {
    ...;
}
The actor. Reading it makes the renderer depend on all of it.
actor
}) => (
<function Transition<TActor extends TransitioningActor>({ actor, children, className, style, }: TransitionProps<TActor>): React.ReactElementTransition
TransitionProps<ResolvedActor & Omit<ResolvedActor, "signals" | "channels"> & { compositing: { blend: "source-over" | "multiply" | "screen" | ... 13 more ... | "plus-lighter"; isolate: boolean; clip: number | null; }; ... 8 more ...; teams: Team[]; } & { ...; }>.actor: ResolvedActor & Omit<ResolvedActor, "signals" | "channels"> & {
    compositing: {
        blend: "source-over" | "multiply" | "screen" | "overlay" | "darken" | "lighten" | "color-dodge" | "color-burn" | "hard-light" | "soft-light" | "difference" | "exclusion" | "hue" | "saturation" | "color" | "luminosity" | "plus-lighter";
        isolate: boolean;
        clip: number | null;
    };
    measuredBox: Rect | undefined;
    layout: {
        x: LayoutValue;
        y: LayoutValue;
        width: number | `${number}%` | Readonly<{
            kind: "layout.percent";
            value: number;
        }>;
        height: number | ... 1 more ... | Readonly<...>;
    };
    ... 6 more ...;
    teams: Team[];
} & {
    ...;
}
The actor resolved for the frame being rendered.
actor
={
actor: ResolvedActor & Omit<ResolvedActor, "signals" | "channels"> & {
    compositing: {
        blend: "source-over" | "multiply" | "screen" | "overlay" | "darken" | "lighten" | "color-dodge" | "color-burn" | "hard-light" | "soft-light" | "difference" | "exclusion" | "hue" | "saturation" | "color" | "luminosity" | "plus-lighter";
        isolate: boolean;
        clip: number | null;
    };
    measuredBox: Rect | undefined;
    layout: {
        x: LayoutValue;
        y: LayoutValue;
        width: number | `${number}%` | Readonly<{
            kind: "layout.percent";
            value: number;
        }>;
        height: number | ... 1 more ... | Readonly<...>;
    };
    ... 6 more ...;
    teams: Team[];
} & {
    ...;
}
The actor. Reading it makes the renderer depend on all of it.
actor
}>
{(
table: ResolvedActor & Omit<ResolvedActor, "signals" | "channels"> & {
    compositing: {
        blend: "source-over" | "multiply" | "screen" | "overlay" | "darken" | "lighten" | "color-dodge" | "color-burn" | "hard-light" | "soft-light" | "difference" | "exclusion" | "hue" | "saturation" | "color" | "luminosity" | "plus-lighter";
        isolate: boolean;
        clip: number | null;
    };
    measuredBox: Rect | undefined;
    layout: {
        x: LayoutValue;
        y: LayoutValue;
        width: number | `${number}%` | Readonly<{
            kind: "layout.percent";
            value: number;
        }>;
        height: number | ... 1 more ... | Readonly<...>;
    };
    ... 6 more ...;
    teams: Team[];
} & {
    ...;
}
table
) => (
<React.JSX.IntrinsicElements.ol: React.DetailedHTMLProps<React.OlHTMLAttributes<HTMLOListElement>, HTMLOListElement>ol> {
table: ResolvedActor & Omit<ResolvedActor, "signals" | "channels"> & {
    compositing: {
        blend: "source-over" | "multiply" | "screen" | "overlay" | "darken" | "lighten" | "color-dodge" | "color-burn" | "hard-light" | "soft-light" | "difference" | "exclusion" | "hue" | "saturation" | "color" | "luminosity" | "plus-lighter";
        isolate: boolean;
        clip: number | null;
    };
    measuredBox: Rect | undefined;
    layout: {
        x: LayoutValue;
        y: LayoutValue;
        width: number | `${number}%` | Readonly<{
            kind: "layout.percent";
            value: number;
        }>;
        height: number | ... 1 more ... | Readonly<...>;
    };
    ... 6 more ...;
    teams: Team[];
} & {
    ...;
}
table
.
signals: Readonly<Record<string, unknown>> & {
    compositing: {
        blend: "source-over" | "multiply" | "screen" | "overlay" | "darken" | "lighten" | "color-dodge" | "color-burn" | "hard-light" | "soft-light" | "difference" | "exclusion" | "hue" | "saturation" | "color" | "luminosity" | "plus-lighter";
        isolate: boolean;
        clip: number | null;
    };
    measuredBox: Rect | undefined;
    layout: {
        x: LayoutValue;
        y: LayoutValue;
        width: number | `${number}%` | Readonly<{
            kind: "layout.percent";
            value: number;
        }>;
        height: number | ... 1 more ... | Readonly<...>;
    };
    ... 6 more ...;
    teams: Team[];
}
signals
.teams: Team[]teams.Array<Team>.map<JSX.Element>(callbackfn: (value: Team, index: number, array: Team[]) => JSX.Element, thisArg?: any): JSX.Element[]
Calls a defined callback function on each element of an array, and returns an array that contains the results.
@paramcallbackfn A function that accepts up to three arguments. The map method calls the callbackfn function one time for each element in the array.@paramthisArg An object to which the this keyword can refer in the callbackfn function. If thisArg is omitted, undefined is used as the this value.
map
((team: Teamteam) => (
<const Shared: React.FC<SharedProps>
Tags a shared element by a declared name — the one way a view marks something `<Transition>` should morph. The kind — box or text — comes from the declaration, so a view cannot pick one per tag. Chrome belongs on the tagged element itself: the background, border, radius and shadow are what the runtime measures and the ghost morphs. Chrome on an inner wrapper would only crossfade. ```tsx const { shared } = Calendar; <Shared name={shared.dayShell(key)} className="rounded-xl bg-white/5"> <Shared name={shared.dayNumber(key)}>{day}</Shared> </Shared> ``` A box whose content fills whatever box it is given — a layout container's hosted child — passes its content as a function of the box. Its ghost then lays each view's content out *at* the morphing box every frame instead of scaling a copy laid out at the measured size, so text and media re-lay out rather than stretch, and nothing clips it: ```tsx <Shared name={Grid.shared.child(child.id)} style={placement}> {(box) => renderChild(box ? { ...child, localLayout: { ...child.localLayout, ...box } } : child)} </Shared> ```
Shared
React.Attributes.key?: React.Key | null | undefinedkey={team: Teamteam.name: stringname} SharedProps.name: SharedName<ViewTransitionElementKind>
A name built by the actor's `defineShared` declaration; never a string.
name
={const row: (name: string) => SharedName<"box">row(team: Teamteam.name: stringname)}>
{team: Teamteam.name: stringname} {team: Teamteam.points: numberpoints} </const Shared: React.FC<SharedProps>
Tags a shared element by a declared name — the one way a view marks something `<Transition>` should morph. The kind — box or text — comes from the declaration, so a view cannot pick one per tag. Chrome belongs on the tagged element itself: the background, border, radius and shadow are what the runtime measures and the ghost morphs. Chrome on an inner wrapper would only crossfade. ```tsx const { shared } = Calendar; <Shared name={shared.dayShell(key)} className="rounded-xl bg-white/5"> <Shared name={shared.dayNumber(key)}>{day}</Shared> </Shared> ``` A box whose content fills whatever box it is given — a layout container's hosted child — passes its content as a function of the box. Its ghost then lays each view's content out *at* the morphing box every frame instead of scaling a copy laid out at the measured size, so text and media re-lay out rather than stretch, and nothing clips it: ```tsx <Shared name={Grid.shared.child(child.id)} style={placement}> {(box) => renderChild(box ? { ...child, localLayout: { ...child.localLayout, ...box } } : child)} </Shared> ```
Shared
>
))} </React.JSX.IntrinsicElements.ol: React.DetailedHTMLProps<React.OlHTMLAttributes<HTMLOListElement>, HTMLOListElement>ol> )} </function Transition<TActor extends TransitioningActor>({ actor, children, className, style, }: TransitionProps<TActor>): React.ReactElementTransition> ), );

<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.

import { function defineShared<const T extends Record<string, SharedDeclaration>>(declarations: T): SharedNames<T>
Declare an actor's shared-element names once, with their kinds. Each entry becomes a builder returning a {@link SharedName } ; the `<Shared>` tag in the renderers accepts only those, so the name set a view publishes is exactly the set the actor declared.
defineShared
, const text: <F extends SharedNameBuilder>(build: F) => SharedTextDeclaration<F>
Mark a declaration as a text element. The default is a box. ```ts static shared = defineShared({ dayShell: (key: string) => `day:${key}:shell`, dayNumber: text((key: string) => `day:${key}:number`), }); ```
text
} from "@motionactor/core";
const
const shared: SharedNames<{
    readonly card: (id: string) => string;
    readonly title: SharedTextDeclaration<(id: string) => string>;
}>
shared
=
defineShared<{
    readonly card: (id: string) => string;
    readonly title: SharedTextDeclaration<(id: string) => string>;
}>(declarations: {
    readonly card: (id: string) => string;
    readonly title: SharedTextDeclaration<(id: string) => string>;
}): SharedNames<{
    readonly card: (id: string) => string;
    readonly title: SharedTextDeclaration<(id: string) => string>;
}>
Declare an actor's shared-element names once, with their kinds. Each entry becomes a builder returning a {@link SharedName } ; the `<Shared>` tag in the renderers accepts only those, so the name set a view publishes is exactly the set the actor declared.
defineShared
({
card: (id: string) => stringcard: (id: stringid: string) => `card:${id: stringid}`, title: SharedTextDeclaration<(id: string) => string>title: text<(id: string) => string>(build: (id: string) => string): SharedTextDeclaration<(id: string) => string>
Mark a declaration as a text element. The default is a box. ```ts static shared = defineShared({ dayShell: (key: string) => `day:${key}:shell`, dayNumber: text((key: string) => `day:${key}:number`), }); ```
text
((id: stringid: string) => `card:${id: stringid}: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.

import { function defineShared<const T extends Record<string, SharedDeclaration>>(declarations: T): SharedNames<T>
Declare an actor's shared-element names once, with their kinds. Each entry becomes a builder returning a {@link SharedName } ; the `<Shared>` tag in the renderers accepts only those, so the name set a view publishes is exactly the set the actor declared.
defineShared
, class LayoutActor<TEvents extends ActorEventMap = {}>LayoutActor,
function signal<T>(): RequiredSignal<T> (+1 overload)
module signal
A timeline value on an actor, and an input to every spawn of it. Without an initial value the signal is required: `ctx.spawn` must supply it.
signal
} from "@motionactor/core";
class class ShelfShelf extends class LayoutActor<TEvents extends ActorEventMap = {}>LayoutActor { static
Shelf.shared: SharedNames<{
    readonly book: (title: string) => string;
}>
shared
=
defineShared<{
    readonly book: (title: string) => string;
}>(declarations: {
    readonly book: (title: string) => string;
}): SharedNames<{
    readonly book: (title: string) => string;
}>
Declare an actor's shared-element names once, with their kinds. Each entry becomes a builder returning a {@link SharedName } ; the `<Shared>` tag in the renderers accepts only those, so the name set a view publishes is exactly the set the actor declared.
defineShared
({
book: (title: string) => stringbook: (title: stringtitle: string) => `book:${title: stringtitle}`, }); Shelf.titles: ActorFieldSignal<string[]>titles = signal<string[]>(initial: string[]): ActorFieldSignal<string[]> (+1 overload)
A timeline value on an actor, and an input to every spawn of it. Without an initial value the signal is required: `ctx.spawn` must supply it.
signal
<string[]>([]);
*Shelf.pull(title: string): Generator<number, void, unknown>pull(title: stringtitle: string) { const const rest: string[]rest = this.Shelf.titles: () => string[]titles().Array<string>.filter(predicate: (value: string, index: number, array: string[]) => unknown, thisArg?: any): string[] (+1 overload)
Returns the elements of an array that meet the condition specified in a callback function.
@parampredicate A function that accepts up to three arguments. The filter method calls the predicate function one time for each element in the array.@paramthisArg An object to which the this keyword can refer in the predicate function. If thisArg is omitted, undefined is used as the this value.
filter
((t: stringt) => t: stringt !== title: stringtitle);
yield* this.LayoutActor<{}>.transition(apply: () => void, options: TransitionOptions): Generator<Frame, void, unknown>
Apply a mutation and project the change: the tree one frame before the mutation morphs into the tree after it, matched by shared-element name. ```ts yield* this.transition(() => this.view.set("week"), { duration: 0.5 }); yield* this.transition(() => this.items.reorder(next), { duration: 1 }); ```
transition
(() => this.Shelf.titles: ActorFieldSignal<string[]>titles.LiveSignal<string[]>.set(value: string[]): void
Set value instantly at current cursor (no yield needed)
set
([title: stringtitle, ...const rest: string[]rest]), {
TransitionOptions.duration: number
Authored seconds for the transition.
duration
: 0.8,
*TransitionOptions.choreography?: ChoreographyProgram | undefined
Per-element motion for elements this program *names*, on top of the morph: its own `progress` through the morph, and an `offset` added to the box the morph puts it on. ```ts yield* grid.transition(() => grid.items.reorder(next), { duration: 0.6, choreography: function* (tx) { yield* tx.stagger(moving, 0.04, function* (badge) { const el = tx.get(Grid.shared.child(badge.id)); yield* el.offset.tween({ y: -22 }, 0.12); yield* el.progress.tween(1, 0.28); yield* el.offset.tween({ y: 0 }, 0.16); }); }, }); ``` Elements it does not name keep the default curve. A program that runs longer than `duration` extends the transition — `duration` is the default curve's length and the floor, not a ceiling — and warns, so the overrun is visible rather than silent.
choreography
(tx: TransitionChoreographytx) {
const const book: ChoreographedElementbook = tx: TransitionChoreographytx.TransitionChoreography.get(name: SharedName | string): ChoreographedElement
The element tagged with this shared name, for authoring.
get
(class ShelfShelf.
Shelf.shared: SharedNames<{
    readonly book: (title: string) => string;
}>
shared
.book: (title: string) => SharedName<"box">book(title: stringtitle));
yield* const book: ChoreographedElementbook.ChoreographedElement.offset: ChoreographyVectoroffset.ChoreographyVector.tween(target: Partial<ChoreographyOffset>, options: Duration | ChoreographyTweenOptions): Generator<Frame, void, unknown>tween({ y?: number | undefinedy: -40 }, 0.2); yield* const book: ChoreographedElementbook.ChoreographedElement.progress: ChoreographyScalarprogress.ChoreographyScalar.tween(target: number, options: Duration | ChoreographyTweenOptions): Generator<Frame, void, unknown>tween(1, 0.4); yield* const book: ChoreographedElementbook.ChoreographedElement.offset: ChoreographyVectoroffset.ChoreographyVector.tween(target: Partial<ChoreographyOffset>, options: Duration | ChoreographyTweenOptions): Generator<Frame, void, unknown>tween({ y?: number | undefinedy: 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: 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.

Was this page helpful?