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/domuses.
For elements, motion values and timelines, use @animaly/dom instead.
pnpm add @animaly/coreSlots 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.
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.
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);import { Engine } from "@animaly/core";
const engine = new Engine();
const x = engine.allocate(0);
engine.set(x, 42);
console.log(engine.get(x)); // 42Drawing a canvas
A renderer is any object with a render method. This one redraws 200 dots, each driven by its own spring.
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 });
});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.
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,
});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
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 });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
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
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");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.
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");| Option | Type | Default | What it does |
|---|---|---|---|
| complete(id) | void | Jumps to the final value. | |
| cancel(id) | void | Returns to the value the animation started from. | |
| stop(id) | void | Holds the current value. |
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 3Knowing 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.
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);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.
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.
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
| Option | Type | Default | What it does |
|---|---|---|---|
| spring | stiffness, damping, mass | 100, 10, 1 | Used when a spring option is left out. |
| tween | duration, ease | 0.3, easeInOut | repeat 0, repeatType loop, repeatDelay 0. |
| inertia | power, timeConstant | 0.8, 325 | bounceStiffness 500, bounceDamping 10, restDelta 0.5. |
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 325import { 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.