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

frame 0·0.00sview "month" · viewFrom "" · viewProgress 1.00

<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 signal and an opacity tween is enough.

On this page