Eight state types. Nested components with independent controllers. Declarative event binding with zero JavaScript. And it all runs in the browser at 60fps.
Every interactive animation eventually hits the same wall: a single state machine can't handle multiple independent concerns without exploding in complexity. A button needs hover states, active states, and a toggle indicator — and each concern should work independently. We built a layered state machine system that solves this, along with nested components, declarative event binding, and a parameter store that makes the whole thing scriptable.
Here's how we did it — and why no web animation library has done it before.
The State Explosion Problem
A flat state machine works fine for a simple button: idle, hovered, pressed, disabled. Four states, a handful of transitions. Done.
Now add a toggle: the button can be on or off. Each visual state needs an on and off variant. That's 4 × 2 = 8 states. Add a loading state and it's 4 × 2 × 2 = 16 states.
The state count is multiplicative, but the transition count is worse — it grows O(N²) with the number of states. For 16 states, you potentially need up to 240 transitions. Most of them duplicate the same logic.
This is the state explosion problem, formalized by David Harel in his 1987 statecharts paper. His solution — orthogonal regions — lets independent concerns run as parallel state machines. Instead of N × M combined states, you model N + M states in two independent regions.
Why Web Animation Libraries Don't Have This
| Library | State Machine? | Layers? | Blend Trees? | Declarative Events? |
|---|---|---|---|---|
| GSAP | No | No | No | No |
| Framer Motion | No | No | No | No |
| Anime.js | No | No | No | No |
| Lottie | No | No | No | No |
| CSS Animations | No | No | No | No |
| Faster | Yes | Yes | 1D, 2D, Direct | Yes |
Every web animation library treats animation as imperative code: "on hover, animate to this value." None of them have state machines. None have layers. None have blend trees. Our system brings game-engine-grade animation state management to the web — declaratively, in a single .fmtion file.
Layers: Independent Concerns Running in Parallel
Each layer is a complete, independent state machine: its own states, transitions, current state, and crossfade tracking. Layers share a parameter store but nothing else.
The resolution rule is priority-based override: layers are ordered by ascending priority. Lower-priority layers apply first, higher-priority layers overwrite. This is not additive blending — if layer 1 sets a bone's rotation to 45°, layer 0's value is completely replaced.
- Layer 0 (hover): idle ↔ hovered, transitions on mouseenter/mouseleave
- Layer 1 (active): off ↔ on, transitions on click toggle
Two layers, 4 total states, 4 transitions. No state explosion.
Architecture
The system is built from four core classes :
StateMachineController orchestrates everything. It owns the layer array, the parameter store, interaction listeners, and nested child controllers.
StateMachineLayer is a single-layer state machine with crossfade tracking. Each layer can have an optional bone mask — implemented via a Proxy on the Skeleton object that intercepts getBone() calls and returns undefined for masked bones.
StateTransition defines condition-based edges with exit time, crossfade duration, early exit, and weighted random selection.
StateMachineParameter supports six types: boolean, number, trigger, string, enum, and color.
The Parameter Store
- Boolean: persistent true/false, with a
toggle()method - Number: continuous value with optional min/max/step quantization
- Trigger: fire-once semantics — the critical distinction from boolean
- String/Enum: discrete values for state selection
- Color: hex color string
Trigger Semantics
Triggers are NOT blanket-reset every frame. Each transition's consumeTriggers() method resets only the specific trigger parameters used in that transition's conditions, immediately after the transition fires. This prevents triggers from being "eaten" by transitions that didn't use them.
Dual Parameter Scope
The controller creates a Proxy around its local parameter Map that checks a shared global ParameterStore first, then falls back to local parameters. This transparently merges global and SM-local parameters — no protocol changes, no copying.
Transitions and Crossfade
Conditions support comparison operators (==, !=, >, <, >=, <=) with type-aware evaluation. Multiple conditions combine in all (AND) or any (OR) mode.
Crossfade Blending
previousState.apply(skeleton, objectMap, (1 - blendWeight) * layerWeight)
currentState.apply(skeleton, objectMap, blendWeight * layerWeight)The blendWeight is eased through four built-in functions (linear, ease-in, ease-out, ease-in-out) plus 30+ presets from the easing library.
When pauseOnExit is enabled, the source animation freezes during crossfade — useful for "freeze frame" transitions.
Instant Transition Chains
The layer's advance(dt) runs a loop (up to 100 iterations) to resolve chains of zero-duration transitions in a single frame. Entry → State A (instant) → State B (instant) → State C (timed) all resolve in one tick. Without this, instant transitions cause visible flickers.
Random Transitions
Weighted random selection enables stochastic animation — idle fidgets, ambient variations, procedural behavior. Each transition has a randomWeight (default 1). The algorithm sums weights, picks a random value, and walks the list.
Eight State Types
AnimationState plays a single clip with three loop modes: none, loop, and pingPong.
BlendState1D is a parameter-driven 1D blend tree. Binary search finds flanking animations, with rest-value fallback when one animation lacks a property.
BlendSpace2D is where it gets interesting. The system runs a full Bowyer-Watson Delaunay triangulation to generate blend triangles. At runtime, barycentric coordinates compute weights within each triangle. This is the same algorithm used in game engine blend spaces — running in the browser.
Think 8-directional character locomotion: place idle at (0,0), walk-forward at (0,1), walk-right at (1,0), run-forward at (0,2). Two parameters smoothly blend between all animations with mathematically correct weights. No web animation library has anything close to this.
BlendStateDirect gives each animation an independent weight — useful for facial expressions where multiple emotions are active simultaneously.
EmptyState holds the current pose. Entry/Exit/Any are structural pseudo-states.
Nested State Machines
Nested components get independent StateMachineController instances with their own state, parameters, and layers. Parent-child communication uses input proxies in three modes: parentToChild, childToParent, or bidirectional (with dirty tracking).
Hit-Test Routing (and the Bug That Almost Shipped)
When the user clicks a child component's button, which controller handles it? collectNestedTargets() recursively walks all nested controllers, collecting interaction targets tagged with their owning controller:
interactiveTargets.push({ id: targetId, obj, events, ctrl: childCtrl });Here's the bug that almost shipped: parent SMs with no own globalListeners but with nested children were being skipped entirely. The original check was if (listeners.length === 0) continue — so if a parent had no direct listeners, its children's targets were never collected. Clicks on nested buttons simply did nothing. No errors. No warnings.
The fix was one line: also check controller.getNestedControllers().size === 0 before skipping. But finding it took hours, because the parent SM was "working fine" — it had no listeners, so skipping seemed correct. The nested child's buttons just... didn't respond. The kind of bug where the code is "correct" but the behavior is wrong.
Declarative Event Binding
Most game engines require code to connect input events to parameters. Our system uses globalListeners — declarative mappings defined in the animation file:
{
"target": "button-bg",
"event": "mouseenter",
"action": { "type": "setTrue", "parameterId": "hovered" }
}No code. The loader registers pointer event handlers, hit-tests against targets, and executes actions: setTrue, setFalse, toggle, fire, setValue, playAudio, switchCamera.
The entire button hover + toggle behavior is specified in .fmtion with zero JavaScript.
Try It: A Complete Interactive Button
Two layers, four states, three event bindings. Zero JavaScript:
{
"stateMachine": {
"parameters": [
{ "name": "hovered", "type": "boolean", "default": false },
{ "name": "active", "type": "boolean", "default": false }
],
"layers": [
{
"name": "hover", "priority": 0,
"states": [
{ "id": "idle", "type": "animation", "animationId": "idle-clip" },
{ "id": "hover", "type": "animation", "animationId": "hover-clip" }
],
"transitions": [
{ "from": "idle", "to": "hover", "duration": 200,
"conditions": [{ "parameterId": "hovered", "value": true }] },
{ "from": "hover", "to": "idle", "duration": 200,
"conditions": [{ "parameterId": "hovered", "value": false }] }
]
},
{
"name": "active", "priority": 1,
"states": [
{ "id": "off", "type": "animation", "animationId": "off-clip" },
{ "id": "on", "type": "animation", "animationId": "on-clip" }
],
"transitions": [
{ "from": "off", "to": "on", "duration": 300,
"conditions": [{ "parameterId": "active", "value": true }] },
{ "from": "on", "to": "off", "duration": 300,
"conditions": [{ "parameterId": "active", "value": false }] }
]
}
],
"globalListeners": [
{ "target": "button-bg", "event": "mouseenter",
"action": { "type": "setTrue", "parameterId": "hovered" } },
{ "target": "button-bg", "event": "mouseleave",
"action": { "type": "setFalse", "parameterId": "hovered" } },
{ "target": "button-bg", "event": "click",
"action": { "type": "toggle", "parameterId": "active" } }
]
}
}The hover layer runs independently of the toggle layer. Crossfade transitions smooth everything out. And it all loads from a single JSON file with one script tag.
Sunny Arora is the founder of FasterHQ. Follow our engineering blog for more deep dives into animation engines, WebAssembly, and the tech behind Faster Creative Cloud.