Skip to content
zabloo

Motion

One transition per node and no trigger list: a resolved animatable value that changes tweens. What animates, what snaps, and the four easing curves in closed form.

Motion here is one idea: a node can say when my appearance changes, ease into the change instead of snapping to it. The shop’s Buy button goes a shade lighter under the pointer; give it a transition and that shade fades in over 120 ms rather than switching. There is no timeline to author and no list of triggers to maintain — if a value the node paints with changes, and the node declared a transition, it tweens.

Formally: a node may declare a transition, and then its animatable values tween instead of jumping whenever they change.

{
  "type": "Button",
  "transition": { "duration": "{motion.fast}", "easing": "ease-out" },
  "style":  { "background": "{color.primary}" },
  "states": { "hover": { "style": { "background": "{color.primary.hover}" } } }
}
PropTypeDefaultDescription
durationDimMilliseconds. A Dim, so motion is themeable ("{motion.fast}"). Zero or less is instant.
easing"linear" | "ease-in" | "ease-out" | "ease-in-out""ease-out"Curve of the ramp.

transition lives on the node, not in style: style is the what, transition the how. Because duration is a token like any other, a “reduce motion” theme sets motion.fast to 0 and stops the whole UI dead without re-emitting a single node.

Keyframes and timelines are not in v1. This is the piece of an animation system the format landed first, and the rest is deferred.

There is no trigger list

A resolved animatable value changed ⇒ it transitions. Whatever caused the change: entering or leaving a state, a SetData on a bound input, a token swapped by a theme hot-update, a relayout that gave the node a new size.

This is what keeps the model small. There is nothing to declare about when to animate, so there is nothing to keep in sync with the states, the bindings or the data.

What animates

Not everything can be interpolated. A colour has a halfway point; a line break does not, and neither does the text in a field. Which side of that line each value falls on is normative, because a value that eases on one target and snaps on another is the same UI behaving differently:

normativeAnimated and snapped values

ValueBehaviourWhy
background, borderColor, colorAnimatesInterpolated componentwise.
opacity, radius, borderWidthAnimatesPlain numeric magnitudes.
width, height, gap, paddingAnimatesInterpolated as layout inputs, before the pass.
fontSizeSnapsIt keys the glyph atlas.
textAlign, textAlignY, lineHeight, wrap, overflow, maxLinesSnapsA re-wrap has no intermediate.
grow, direction, justify, alignSnapsDiscrete layout inputs.
visible, disabled, clip, text, open, src, fit, axis, scrollbarSnapsStructure and content, not magnitudes.
A control's value — Slider's value/min/max/step, TextInput's value/placeholder/maxLengthSnapsState the player is dragging or typing, not a visual magnitude to catch up with.
Overlay's modal, z, autoCloseMsSnapsz and autoCloseMs are numbers, but they are ordering and timing.

Colors are interpolated componentwise in straight sRGB with straight alpha.

Layout dimensions are interpolated as inputs, before the layout pass — never as computed rects. The SDK tweens the declared width, height, gap and padding and then runs its ordinary layout with the interpolated values, so there is still exactly one layout pass per frame and a computed rect never feeds back into its own input.

Endpoints

  • Both endpoints must resolve. An undefined (auto) endpoint has nothing to tween from or to, so the change snaps. Sizes and declared colors are where this shows up; opacity, radius, borderWidth, gap and padding have renderer defaults, so they always resolve and a state that introduces one still animates.
  • Mounting snaps, and so does an envelope reload: there is no previous value to tween from.
  • An interruption retargets from the value currently on screen, over a full duration — a button released mid-hover-fade continues from where it is rather than restarting.
  • An undeclared borderColor holds the last one instead of dropping to no value. The border that is leaving is leaving through borderWidth, and a focus ring that lost its color halfway out would flash the missing-color magenta on its way.
  • A node out of layout forgets its animation state; when it comes back, it snaps. There is no honest previous value for a node that was not on screen.

No cascade

transition is read from the base node only. A node never inherits its parent’s, and a state override cannot carry one — an asymmetric in/out transition is a compatible future extension, not v1 surface.

In authoring, motion resolves like a variant: node prop, then variant, then theme default for that component — and the most specific declaration wins whole, since it is one object rather than a set of fields to merge.

Easing

An easing curve is what makes a fade feel like it slows down at the end instead of moving at a constant rate. There are four, and their shapes are written out as arithmetic so that a button on a phone and the same button on a console are the same colour at the same millisecond.

normative

Four curves, defined as closed-form cubic polynomials rather than CSS cubic-béziers, so every target computes the same number without a solver:

Curvef(t)
lineart
ease-in
ease-out1 − (1 − t)³
ease-in-outt < 0.5 ? 4t³ : 1 − (−2t + 2)³ / 2

t outside 0..1 clamps. An unknown curve — newer content on an older reader — falls back to linear rather than refusing to animate.

Reference implementation: easeProgress in @zabloo/format.

Motion a component owns

A component’s own behaviour may drive this same machinery with endpoints it computes. When it does, the tween is part of that component’s spec rather than a value of the animatable set above — which is why none of these props appears in it:

ComponentWhat its transition does
ProgressBarTweens the value, then lays out the fill from it. The fill's rect is never the thing interpolated.
SpinnerLoops forever on period, pulsing its children's opacity. The only thing in the format that repeats without end.
OverlayFades its whole layer presence in and out, so a closing overlay stays on screen for exactly one duration after visible went false.
CollapseAnimates its own height between closed and its content's natural height.
ToggleCrossfades its two indicator slots, which share a box.
SliderGlides to a value the game pushed; a value the player is dragging never lags behind the finger.
visible itself never animates

An Overlay that is fading out is only pixels: input, the focus trap and the timers all read the live layer, which it has already left.