animaly
Documentation menu

Reference

Core engine

@animaly/core is the engine under @animaly/dom. It knows nothing about the DOM: it stores numbers in typed arrays, steps springs, tweens, keyframes and inertia, and hands the values that changed to your renderers once per frame.

When to use the core directly

  • You draw to a canvas or WebGL and want thousands of animated values without creating objects for each.
  • You write unit tests that step time by hand instead of waiting for real frames.
  • You build your own renderer or binding on top of the same drivers @animaly/dom uses.

For elements, motion values and timelines, use @animaly/dom instead.

Install the engine
pnpm add @animaly/core

Slots and renderers

engine.allocate(initial) returns a slot id for one number. Start a driver on it with tween, spring, keyframes or inertia. Each frame, the engine calls every renderer with the store and the indexes of the slots that changed.

Allocate a value, tween it and render it
import { Engine } from "@animaly/core";

const engine = new Engine();
const opacity = engine.allocate(0);

engine.addRenderer({
  render(store, dirty, count) {
    for (let i = 0; i < count; i++) {
      console.log("value", store.value[dirty[i]]);
    }
  },
});

engine.tween(opacity, 1, { duration: 0.4, ease: "easeOut" });

store.value is a Float64Array indexed by store index. Convert a slot id with store.indexOf(id), or read through the engine with get, current, getVelocity, time and length.

Read value, velocity and progress
import { Engine } from "@animaly/core";

const engine = new Engine();
const x = engine.allocate(0);
engine.spring(x, 300, { stiffness: 200, damping: 20 });

setTimeout(() => {
  console.log({
    value: engine.current(x),
    velocity: engine.getVelocity(x),
    time: engine.time(x),
    length: engine.length(x),
    animating: engine.isAnimating(x),
  });
}, 120);
Set a value without animating
import { Engine } from "@animaly/core";

const engine = new Engine();
const x = engine.allocate(0);

engine.set(x, 42);
console.log(engine.get(x)); // 42

Drawing a canvas

A renderer is any object with a render method. This one redraws 200 dots, each driven by its own spring.

Draw a canvas from a custom renderer
import { Engine } from "@animaly/core";

const canvas = document.querySelector<HTMLCanvasElement>(".stage")!;
const context = canvas.getContext("2d")!;
const engine = new Engine();

const dots = Array.from({ length: 200 }, (_, index) => ({
  x: (index % 20) * 24 + 12,
  y: engine.allocate(0),
}));

engine.addRenderer({
  render(store) {
    context.clearRect(0, 0, canvas.width, canvas.height);
    context.fillStyle = "#7c3aed";
    for (const dot of dots) {
      context.beginPath();
      context.arc(dot.x, store.value[store.indexOf(dot.y)], 4, 0, Math.PI * 2);
      context.fill();
    }
  },
});

dots.forEach((dot) => {
  engine.spring(dot.y, 40 + Math.random() * 160, { stiffness: 120, damping: 8 });
});
Animate ten thousand values in one batch
import { Engine } from "@animaly/core";

const engine = new Engine(undefined, 10_000);
const values = Array.from({ length: 10_000 }, () => engine.allocate(0));

engine.batch(() => {
  values.forEach((id, index) => {
    engine.spring(id, index % 100, { stiffness: 180, damping: 18 });
  });
});

batch groups many driver starts into one update. The second argument of new Engine sets the starting capacity; the store grows when you allocate more.

Drivers

Tweens

Durations are in seconds. ease takes a name (linear, easeIn, easeOut, easeInOut), four cubic bezier numbers, or a function from progress to progress.

Tween with a cubic bezier and a mirrored repeat
import { Engine } from "@animaly/core";

const engine = new Engine();
const pulse = engine.allocate(1);

engine.tween(pulse, 1.2, {
  duration: 0.6,
  ease: [0.34, 1.56, 0.64, 1],
  repeat: 3,
  repeatType: "mirror",
  repeatDelay: 0.1,
});
Use your own easing function
import { Engine } from "@animaly/core";

const engine = new Engine();
const x = engine.allocate(0);
const steps = (progress: number): number => Math.floor(progress * 5) / 5;

engine.tween(x, 100, { duration: 1, ease: steps });

Springs

Spring by stiffness and damping, or by duration
import { Engine } from "@animaly/core";

const engine = new Engine();
const bouncy = engine.allocate(0);
const timed = engine.allocate(0);

engine.spring(bouncy, 1, { stiffness: 400, damping: 12, mass: 1 });
engine.spring(timed, 1, { duration: 0.5 });
Start a spring with a velocity
import { Engine } from "@animaly/core";

const engine = new Engine();
const x = engine.allocate(0);

engine.spring(x, 0, { velocity: 2000, stiffness: 250, damping: 15 });

Keyframes

Keyframes with times and an ease per segment
import { Engine, ManualScheduler } from "@animaly/core";

const clock = new ManualScheduler();
const engine = new Engine(clock);
const y = engine.allocate(0);

engine.keyframes(y, [0, 50, 20, 100], {
  duration: 1,
  times: [0, 0.2, 0.6, 1],
  ease: ["easeOut", "easeInOut", "linear"],
});

clock.advance(200);
if (engine.get(y) !== 50) throw new Error("expected the second keyframe at 0.2s");

Inertia

Glide to a stop and bounce off a limit
import { Engine, ManualScheduler } from "@animaly/core";

const clock = new ManualScheduler();
const engine = new Engine(clock);
const x = engine.allocate(0);

engine.inertia(x, { velocity: 1000, max: 120, bounceStiffness: 500, bounceDamping: 10 });

clock.frames(120, 16);
if (Math.round(engine.get(x)) !== 120) throw new Error("expected to rest at the max of 120");
Snap an inertia target to a grid
import { Engine } from "@animaly/core";

const engine = new Engine();
const scroll = engine.allocate(0);

engine.inertia(scroll, {
  velocity: 1800,
  power: 0.8,
  timeConstant: 325,
  modifyTarget: (ideal) => Math.round(ideal / 320) * 320,
});

Playback per value

Every slot has its own clock. pause, play, speed and seek act on one slot; seek takes seconds.

Pause, speed up and seek one value
import { Engine, ManualScheduler } from "@animaly/core";

const clock = new ManualScheduler();
const engine = new Engine(clock);
const x = engine.allocate(0);
engine.tween(x, 100, { duration: 1, ease: "linear" });

clock.advance(250);
engine.pause(x);
clock.advance(500);
if (engine.get(x) !== 25) throw new Error("a paused value does not move");

engine.play(x);
engine.speed(x, 2);
engine.seek(x, 0.5);
if (engine.get(x) !== 50) throw new Error("seek jumps to 0.5s");
Ending an animation early
OptionTypeDefaultWhat it does
complete(id)voidJumps to the final value.
cancel(id)voidReturns to the value the animation started from.
stop(id)voidHolds the current value.
complete, cancel and stop
import { Engine, ManualScheduler } from "@animaly/core";

const clock = new ManualScheduler();
const engine = new Engine(clock);
const [a, b, c] = [engine.allocate(0), engine.allocate(0), engine.allocate(0)];
[a, b, c].forEach((id) => engine.tween(id, 10, { duration: 1, ease: "linear" }));
clock.advance(300);

engine.complete(a);
engine.cancel(b);
engine.stop(c);

console.log(engine.get(a), engine.get(b), engine.get(c)); // 10 0 3

Knowing when values rest

watch(id) covers the animation that is running on a slot, so call it right after tween, spring or another driver; starting a new animation clears the watch, and you watch again after each start, as @animaly/dom does. Listeners added with onSettle receive an Int32Array of store indexes and a count, so delivering a settle allocates nothing. Read values with store.value[index]. At the DOM level, use playback controls and finished instead.

Get told when values come to rest
import { Engine } from "@animaly/core";

const engine = new Engine();
const x = engine.allocate(0);

engine.onSettle((settled, count) => {
  for (let i = 0; i < count; i++) {
    if (settled[i] === engine.store.indexOf(x)) console.log("x is at rest", engine.get(x));
  }
});

engine.tween(x, 100, { duration: 0.3 });
engine.watch(x);
Release values you are done with
import { Engine } from "@animaly/core";

const engine = new Engine();
const temporary = engine.allocate(0);

engine.tween(temporary, 1, { duration: 0.2 });
engine.release(temporary);

Testing with a manual scheduler

ManualScheduler replaces the browser's frame clock. advance(ms) moves time forward and runs one frame; frames(count, ms) runs several. Results are exact, so tests can compare numbers directly.

Step time by hand in a unit test
import { Engine, ManualScheduler } from "@animaly/core";

const clock = new ManualScheduler();
const engine = new Engine(clock);
const x = engine.allocate(0);

engine.tween(x, 100, { duration: 0.5, ease: "linear" });

clock.advance(250);
if (engine.get(x) !== 50) throw new Error("expected 50 halfway through");

clock.frames(30, 16);
if (engine.get(x) !== 100 || engine.isAnimating(x)) throw new Error("expected to finish at 100");

Your own scheduler

A scheduler is now() in milliseconds plus request(callback). Without one, the engine uses browserScheduler(), which runs on requestAnimationFrame.

Bring your own frame scheduler
import { Engine, type Scheduler } from "@animaly/core";

const frames: Scheduler = {
  now: () => performance.now(),
  request: (callback) => {
    requestAnimationFrame(callback);
  },
};

const engine = new Engine(frames);
const x = engine.allocate(0);
engine.spring(x, 1);

Defaults and eases

Driver defaults
OptionTypeDefaultWhat it does
springstiffness, damping, mass100, 10, 1Used when a spring option is left out.
tweenduration, ease0.3, easeInOutrepeat 0, repeatType loop, repeatDelay 0.
inertiapower, timeConstant0.8, 325bounceStiffness 500, bounceDamping 10, restDelta 0.5.
Read the defaults
import { inertiaDefaults, springDefaults, tweenDefaults } from "@animaly/core";

console.log(springDefaults.stiffness, springDefaults.damping, springDefaults.mass); // 100 10 1
console.log(tweenDefaults.duration, tweenDefaults.ease); // 0.3 "easeInOut"
console.log(inertiaDefaults.power, inertiaDefaults.timeConstant); // 0.8 325
Evaluate the named eases yourself
import { cubicBezierAt, namedEases } from "@animaly/core";

const [x1, y1, x2, y2] = namedEases.easeOut;
console.log(cubicBezierAt(x1, y1, x2, y2, 0.5).toFixed(3));

Performance

The frame loop allocates almost nothing: the engine's allocation test measures under 128 bytes per frame with 2,000 running animations, so the garbage collector stays idle while animations run.

The core has no DOM code. The DOM package adds one combined transform write per element on top of it, and an opt-in compositor path for transforms and opacity.