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}" } } }
}
| Prop | Type | Default | Description |
|---|---|---|---|
| duration | Dim | — | Milliseconds. 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
| Value | Behaviour | Why |
|---|---|---|
background, borderColor, color | Animates | Interpolated componentwise. |
opacity, radius, borderWidth | Animates | Plain numeric magnitudes. |
width, height, gap, padding | Animates | Interpolated as layout inputs, before the pass. |
fontSize | Snaps | It keys the glyph atlas. |
textAlign, textAlignY, lineHeight, wrap, overflow, maxLines | Snaps | A re-wrap has no intermediate. |
grow, direction, justify, align | Snaps | Discrete layout inputs. |
visible, disabled, clip, text, open, src, fit, axis, scrollbar | Snaps | Structure and content, not magnitudes. |
A control's value — Slider's value/min/max/step, TextInput's value/placeholder/maxLength | Snaps | State the player is dragging or typing, not a visual magnitude to catch up with. |
Overlay's modal, z, autoCloseMs | Snaps | z 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,gapandpaddinghave 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
borderColorholds the last one instead of dropping to no value. The border that is leaving is leaving throughborderWidth, 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.
normativeFour curves, defined as closed-form cubic polynomials rather than CSS cubic-béziers, so every target computes the same number without a solver:
| Curve | f(t) |
|---|---|
| linear | t |
| ease-in | t³ |
| ease-out | 1 − (1 − t)³ |
| ease-in-out | t < 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:
| Component | What its transition does |
|---|---|
| ProgressBar | Tweens the value, then lays out the fill from it. The fill's rect is never the thing interpolated. |
| Spinner | Loops forever on period, pulsing its children's opacity. The only thing in the format that repeats without end. |
| Overlay | Fades its whole layer presence in and out, so a closing overlay stays on screen for exactly one duration after visible went false. |
| Collapse | Animates its own height between closed and its content's natural height. |
| Toggle | Crossfades its two indicator slots, which share a box. |
| Slider | Glides to a value the game pushed; a value the player is dragging never lags behind the finger. |
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.