Skip to content

Animation API Reference

The Balancy bridge exposes two animation systems through the global balancy object:

  • Keyframe animations (balancy.createTimeline) — plays .banim clips 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

  1. Keyframe Animations
  2. createTimeline
  3. BalancyTimeline
  4. The file:Animation field
  5. Animation events
  6. Lottie Playback
  7. createLottieAnimation
  8. destroyLottieAnimation
  9. getLottieAnimation
  10. Script Examples
  11. Single-clip Animator
  12. Multi-clip AnimationController
  13. Animated Properties
  14. 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: loop is 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
}
  • data contains tracks, keyframes, duration, events, canvas scaler settings, etc.
  • elements maps Unity hierarchy paths (e.g. "Panel/Title") to resolved ElementObject instances 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 .finished promise, 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:Animation fields are null when unassigned in UI Builder. Always guard before calling .add().