View transitions
Switching between named views with a shared-element morph — the runtime resolves the morph for any frame, the binding measures and paints.
A view transition is resolved actor state. For any frame, compiled.at(frame) answers
which view is showing, which is leaving, how the two layers crossfade, and — for every
element that appears in both views — the transform that carries its box in one view toward
its box in the other. The model is the CSS View Transitions API (shared identity by name,
geometry morph first, crossfade only as fallback), driven by a frame instead of a clock.
The split is: the runtime owns the morph, a framework binding measures the real tree and paints the answer. Nothing about interpolation, easing, stagger, ancestor-countering or crossfade policy lives in the binding.
The actor's half
One declared field, and a command that sequences it:
import { setView, viewTransition } from "@motionactor/core";
class Calendar extends LayoutActor {
reveal = viewTransition<"month" | "week" | "day">("month");
*showView(next: "month" | "week" | "day") {
yield* setView(this.reveal, next, {
duration: 0.6,
easing: Easing.inOutCubic,
stagger: { amount: 0.3 },
});
}
}viewTransition(initial) declares a group of the switch's own
state (view, viewFrom, viewProgress, the authored easing and stagger) plus the two
geometry channel maps the binding publishes into. setView latches viewFrom, sets view,
tweens viewProgress 0 → 1 linearly, and clears viewFrom when settled.
Easing and stagger are arguments to the command, not props on a component: they are authored choreography, they belong on the timeline, and they must be identical in every binding. The signal stays linear time; the resolver applies the curve to the picture.
What resolves
const reveal = compiled.at(41).scene.calendar.reveal;
reveal.view; // "week"
reveal.viewFrom; // "month" — "" when settled
reveal.progress; // 0.42, the raw linear signal
reveal.layers.from.opacity; // 0.3
reveal.elements["day:11:number"].from.transform // { tx, ty, sx, sy }Prop
Type
Affine is { tx, ty, sx, sy } — numbers, not a CSS string, so a canvas or SVG binding
formats it its own way. Every element morphs to its own absolute target; a nested shared
element counters its nearest shared ancestor's transform (anc⁻¹ ∘ self) so it lands where
it belongs no matter how differently the two views nest it. A day number can be a cell corner
in month view and a column-header centre in week view.
Because this is resolution, it is testable with no DOM at all: publish two rects, ask for a frame, assert the affine.
The renderer's half
<ViewTransition> from @motionactor/renderers/view-transition takes the resolved field and
its geometry maps. Settled, it renders the active view once. In flight, it renders both views
stacked and paints the opacities and transforms the runtime handed it.
import { ViewTransition, ViewTransitionElement } from "@motionactor/renderers/view-transition";
const reveal = calendar.reveal;
<ViewTransition state={reveal} geometry={reveal.geometry}>
{(activeView) => (
<Grid view={activeView}>
{days.map((day) => (
<ViewTransitionElement key={day} name={`day:${day}:shell`} kind="box">
<ViewTransitionElement name={`day:${day}:number`} kind="text">
{day}
</ViewTransitionElement>
</ViewTransitionElement>
))}
</Grid>
)}
</ViewTransition>In the scene above the shared names are the seven day shells and their day numbers; the other 28 month cells have no counterpart in the week view, so they ride the crossfade.
Prop
Type
Where the geometry comes from
Each <ViewTransitionElement> measures itself in a layout effect and publishes
{ rect, ancestor, kind } into its layer's channel map — the rect in container-local
pixels (the binding divides out any CSS scale on the container, which the Remotion preview
applies), and ancestor the name of the nearest tagged ancestor in that layer's tree.
Both are facts the binding can read off its own tree; what they mean is runtime math.
Measurement happens on one commit per switch, with the layers rendered untransformed —
getBoundingClientRect reports transforms, so measuring every commit would feed the morph
back in as if it were layout. The maps record per frame, so scrubbing back replays the rects
that were actually measured then.
Writing a binding for another framework
Two jobs, and no math: publish each tagged element's rectangle into the layer's channel map,
and paint state.elements[name][layer] plus state.layers.*.opacity. The repo's
docs/framework-adapters.md enumerates the full adapter surface.
Use it for
- Switching between named modes — month / week / day, list / grid, collapsed / expanded.
- Any renderer that needs the outgoing state as well as the incoming one.
Do not use it for
- Geometry moving between two measured boxes inside one live tree. That is a layout transition.
- A plain boolean toggle with a fade — a single
signaland an opacity tween is enough.