Animation API Reference¶
The Balancy bridge exposes two animation systems through the global balancy object:
- Keyframe animations (
balancy.createTimeline) — plays.banimclips that animate CSS properties (transform, opacity, color, size, visibility) on UI elements. Powered by Anime.js v4 internally. - Lottie playback (
balancy.createLottieAnimation) — plays Lottie JSON files (baked particle effects, motion graphics) inside container elements. Powered by lottie-web internally.
Scripts never interact with Anime.js or lottie-web directly.
Table of Contents¶
- Keyframe Animations
- createTimeline
- BalancyTimeline
- The file:Animation field
- Animation events
- Lottie Playback
- createLottieAnimation
- destroyLottieAnimation
- getLottieAnimation
- Script Examples
- Single-clip Animator
- Multi-clip AnimationController
- Animated Properties
- Lifecycle Best Practices
Keyframe Animations¶
balancy.createTimeline(options?)¶
Creates a new animation timeline. Returns a BalancyTimeline instance.
const tl = balancy.createTimeline();
const tl = balancy.createTimeline({ loop: true });
const tl = balancy.createTimeline({ loop: 3, alternate: true });
Options:
| Option | Type | Default | Description |
|---|---|---|---|
autoplay |
boolean |
false |
Start playing immediately after .add() |
loop |
boolean \| number |
false |
true for infinite loop, or a number for N repeats |
alternate |
boolean |
false |
Reverse direction on each loop iteration (ping-pong) |
Note:
loopis set at creation time and cannot be changed after. If you need to switch between looping and non-looping for the same clip, create a new timeline.
BalancyTimeline¶
The object returned by createTimeline(). Implements the AnimationController interface.
.add(animData, offset?)¶
Adds a .banim clip to the timeline. Can be called multiple times to sequence or layer clips.
// Single clip
tl.add(this.animation);
// Sequential clips with ms offset
tl.add(this.panelAnim, 0);
tl.add(this.titleAnim, 200); // starts 200ms after timeline start
// Relative offset (starts 100ms before the previous clip ends — overlap)
tl.add(this.buttonAnim, '-=100');
// Relative offset (starts 300ms after the previous clip ends — gap)
tl.add(this.footerAnim, '+=300');
Parameters:
| Param | Type | Description |
|---|---|---|
animData |
AnimationData |
A deserialized file:Animation field (see below) |
offset |
number \| string |
Optional. Absolute ms offset, or relative string like '+=200' / '-=100' |
Overrides form — remap elements at add-time:
tl.add(animData, {
elements: { 'Panel/Title': someOtherElementObject },
offset: 200,
});
Returns this for chaining: tl.add(clipA).add(clipB, 200).
.play()¶
Start or resume playback.
tl.play();
.pause()¶
Pause playback at the current position.
tl.pause();
.restart()¶
Restart from the beginning. Resets fired events and the finished promise.
tl.restart();
.cancel()¶
Stop playback and revert all animated elements to their pre-animation CSS state. Resolves the finished promise immediately.
tl.cancel();
.finished¶
A Promise<void> that resolves when the animation completes (or when .cancel() is called).
tl.play();
await tl.finished;
console.log('Animation done');
.on(eventName, callback)¶
Subscribe to animation events embedded in the .banim file (from Unity's AnimationEvent). Returns this for chaining.
tl.on('hit', () => {
console.log('Hit event fired');
});
The file:Animation serialized field¶
In a Balancy script, declare an animation field with the @serialize {file:Animation} comment:
// @serialize {file:Animation}
animation = null;
The bridge deserializes this into an AnimationData object before the script's start() runs:
{
data: BalancyAnimationDocument, // parsed .banim JSON
elements: Record<string, ElementObject> // path -> resolved element
}
datacontains tracks, keyframes, duration, events, canvas scaler settings, etc.elementsmaps Unity hierarchy paths (e.g."Panel/Title") to resolvedElementObjectinstances in the DOM.
You pass this object directly to tl.add() — the timeline handles element resolution, keyframe conversion, viewport scaling, and color compositing internally.
Animation events¶
.banim files can contain named events at specific timestamps (exported from Unity's m_Events). Subscribe with .on():
const tl = balancy.createTimeline();
tl.add(this.animation);
tl.on('showParticles', () => { /* ... */ });
tl.on('playSound', () => { /* ... */ });
tl.play();
Events fire once when the timeline's current time crosses the event's timestamp. For looping timelines ({ loop: true }), events do not re-fire on subsequent iterations — they are tracked by a "name@time" key that is only cleared by .restart(). If you need events to fire every loop, use separate non-looping timelines in a manual loop (see the AnimationController example).
Lottie Playback¶
balancy.createLottieAnimation(container, fileId)¶
Note: You usually won't need to call this directly. All elements with a Lottie type are automatically prepared (fetched, instantiated, and started) before the view is opened. This method is available for advanced use cases where you need to manually create a Lottie instance at runtime.
Create a lottie-web animation instance inside a DOM container element.
const anim = await balancy.createLottieAnimation(containerElement, 'file-id-123');
Parameters:
| Param | Type | Description |
|---|---|---|
container |
HTMLElement |
The DOM element to render into |
fileId |
string |
Balancy Data Object ID of the Lottie JSON file |
Returns: Promise<AnimationItem> — a lottie-web AnimationItem instance.
Behavior:
- Fetches the Lottie JSON (cached after first fetch)
- Reads loop, autoplay, and meta.renderer from the JSON root
- Defaults to canvas renderer; switches to SVG when meta.renderer is 'svg'
- Applies outerScale from bake options if present
- For SVG renderer, removes inline width/height so the animation fills its container via CSS
The returned AnimationItem is the standard lottie-web instance. You can call .play(), .pause(), .stop(), .goToAndPlay(), etc. on it directly. See the lottie-web documentation for the full API.
balancy.destroyLottieAnimation(anim)¶
Safely destroy a lottie-web instance and clean up resources.
balancy.destroyLottieAnimation(anim);
Accepts null or undefined safely (no-op).
balancy.getLottieAnimation(element)¶
Look up the active lottie-web instance for a container element (only works for instances created via the auto-init system with data-lottie-id).
const anim = balancy.getLottieAnimation(containerElement);
if (anim) anim.pause();
Returns AnimationItem | null.
Script Examples¶
Single-clip Animator¶
The Animator component plays a single .banim clip with configurable autoplay, loop, and play-on-enable behavior.
class MyAnimator extends balancy.ElementBehaviour {
// @serialize {file:Animation}
animation = null;
// @serialize {boolean}
autoplay = false;
// @serialize {boolean}
loop = false;
_timeline = null;
start() {
if (!this.animation) return;
// Create timeline with loop setting (set at creation time)
this._timeline = balancy.createTimeline({ loop: this.loop });
this._timeline.add(this.animation);
if (this.autoplay) {
this._timeline.play();
}
}
// Play and wait for completion
async show() {
this.cancel();
if (this._timeline) {
this._timeline.play();
await this._timeline.finished;
}
}
cancel() {
if (this._timeline) this._timeline.cancel();
}
onDisable() {
if (this._timeline) this._timeline.pause();
}
onDestroy() {
this.cancel();
}
}
Multi-clip AnimationController¶
The AnimationController orchestrates switching between multiple animation clips — for example, cycling through idle and start variants.
class MyController extends balancy.ElementBehaviour {
// @serialize {file:Animation}
idleA = null;
// @serialize {file:Animation}
idleB = null;
// @serialize {file:Animation}
startA = null;
// @serialize {file:Animation}
startB = null;
// @serialize {number}
loopCount = 3;
_stopped = false;
_currentTimeline = null;
init() {
this._stopped = false;
this._runSequence();
}
cancel() {
this._stopped = true;
if (this._currentTimeline) {
this._currentTimeline.cancel();
this._currentTimeline = null;
}
}
// Play a single clip once, return a promise
_playClip(animData) {
const tl = balancy.createTimeline();
tl.add(animData);
this._currentTimeline = tl;
tl.play();
return tl.finished;
}
// Repeating sequence: start-a -> idle-a (x loopCount) -> start-b -> idle-b (x loopCount) -> repeat
async _runSequence() {
while (!this._stopped) {
if (this.startA) {
await this._playClip(this.startA);
if (this._stopped) break;
}
if (this.idleA) {
for (let i = 0; i < this.loopCount; i++) {
if (this._stopped) break;
await this._playClip(this.idleA);
}
if (this._stopped) break;
}
if (this.startB) {
await this._playClip(this.startB);
if (this._stopped) break;
}
if (this.idleB) {
for (let i = 0; i < this.loopCount; i++) {
if (this._stopped) break;
await this._playClip(this.idleB);
}
if (this._stopped) break;
}
}
}
onDisable() {
if (this._currentTimeline) this._currentTimeline.pause();
}
onDestroy() {
this.cancel();
}
}
Key pattern: each _playClip call creates a fresh non-looping timeline, plays it, and awaits .finished. The outer while loop handles the sequencing. Calling .cancel() sets _stopped = true and cancels the current timeline, which resolves its .finished promise and breaks the loop.
Caching timelines with .restart()¶
Creating a new timeline for every playback works, but does redundant work (re-parsing keyframes, re-extracting layout, re-normalizing transforms). For clips that play repeatedly, you can build the timeline once and reuse it with .restart():
class MyController extends balancy.ElementBehaviour {
// @serialize {file:Animation}
idleA = null;
// @serialize {file:Animation}
startA = null;
// @serialize {number}
loopCount = 3;
_stopped = false;
_currentTimeline = null;
// Cache: animData -> pre-built timeline
_cache = new Map();
init() {
this._stopped = false;
// Pre-build timelines for each non-null clip
for (const animData of [this.idleA, this.startA]) {
if (!animData) continue;
const tl = balancy.createTimeline();
tl.add(animData);
this._cache.set(animData, tl);
}
this._runSequence();
}
_playClip(animData) {
const tl = this._cache.get(animData);
if (!tl) return Promise.resolve();
this._currentTimeline = tl;
tl.restart(); // rewinds, resets events, creates new .finished promise
return tl.finished;
}
async _runSequence() {
while (!this._stopped) {
if (this.startA) {
await this._playClip(this.startA);
if (this._stopped) break;
}
if (this.idleA) {
for (let i = 0; i < this.loopCount; i++) {
if (this._stopped) break;
await this._playClip(this.idleA);
}
if (this._stopped) break;
}
}
}
cancel() {
this._stopped = true;
if (this._currentTimeline) {
this._currentTimeline.cancel();
this._currentTimeline = null;
}
}
onDisable() {
if (this._currentTimeline) this._currentTimeline.pause();
}
onDestroy() {
this.cancel();
}
}
When to use: clips that play many times (idle loops, repeating effects). The first .add() does the expensive work; subsequent .restart() calls just rewind and replay.
Animated Properties¶
The .banim format supports these CSS properties (converted from Unity AnimationClip curves):
| .banim property | CSS property | Notes |
|---|---|---|
translateX, translateY |
transform: translateX/Y |
Pixel values, viewport-scaled |
scaleX, scaleY |
transform: scaleX/Y |
Unitless multipliers |
rotate |
transform: rotate |
Degrees |
opacity |
opacity |
0-1 |
width, height |
width, height |
Pixel values, viewport-scaled. Elements are automatically extracted from document flow during animation. |
colorR/G/B/A |
backgroundColor |
Composited into rgba(). If only colorA is animated (no RGB), it maps to opacity instead. |
textColorR/G/B/A |
color |
Composited into rgba(). If only textColorA is animated, it maps to opacity instead. |
active |
display |
0 = display: none, 1 = restore original display value |
Viewport scaling: Pixel-based properties (translateX, translateY, width, height) are scaled from the baked reference resolution to the runtime viewport, so animations match vw/vh-based layouts at any screen size. This is controlled by the canvasScaler and referenceResolution fields in the .banim document.
Transform order: The bridge normalizes inline transforms to Translate-Rotate-Scale (TRS) order before animation, matching Unity's SRT composition. Partial transform animations (e.g. only scale) automatically preserve non-animated components (e.g. existing translate).
Lifecycle Best Practices¶
| Hook | Recommended action |
|---|---|
start() |
Build timelines with createTimeline() + .add(). Start autoplay if needed. |
onEnable() |
Resume or replay if appropriate for your component. |
onDisable() |
Call .pause() on active timelines so they can resume later. |
onDestroy() |
Call .cancel() to revert elements and release resources. |
- Always cancel on destroy.
.cancel()reverts animated elements to their pre-animation CSS and resolves the.finishedpromise, preventing dangling awaits. - Create fresh timelines for one-shot playback. If you need to play a clip multiple times sequentially (like idle loops), create a new timeline each time rather than reusing one, since timeline state is not reset by
.play(). - Check for null animation fields. Serialized
file:Animationfields arenullwhen unassigned in UI Builder. Always guard before calling.add().