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[]): voidSet value instantly at current cursor (no yield needed)set(next: Team[]next), {
TransitionOptions.duration: numberAuthored 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.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.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[]): voidSet value instantly at current cursor (no yield needed)set([title: stringtitle, ...const rest: string[]rest]), {
TransitionOptions.duration: numberAuthored seconds for the transition.duration: 0.8,
*TransitionOptions.choreography?: ChoreographyProgram | undefinedPer-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): ChoreographedElementThe 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.