Anime.js: A JavaScript Animation Engine Worth Reading

November 7, 2024

|repo-review

by Florian Narr

Anime.js: A JavaScript Animation Engine Worth Reading

Anime.js is a JavaScript animation library that animates CSS properties, SVG, DOM attributes, and plain JavaScript objects through a unified API. Version 4 is a ground-up rewrite — different enough from v3 that a migration guide exists — and at 67k stars it's one of the most-watched animation libraries on GitHub.

Why I starred it

The v4 rewrite landed with a clean ES module export structure and a linked-list render loop that looked nothing like the array-iteration approach most libraries take. I wanted to understand how an animation engine achieves consistent frame timing across different environments and handles composition across overlapping tweens. The source is all vanilla JS, no build-time abstractions, so it's readable end to end.

How it works

The architecture centers on a class hierarchy: ClockEngine (and Timer, Animation, Timeline). Clock in src/core/clock.js is the base — it tracks _currentTime, _lastTickTime, _fps, _speed, and most importantly manages the doubly linked list of children via _head and _tail pointers.

// src/core/clock.js
export class Clock {
  constructor(initTime = 0) {
    this._frameDuration = K / maxFps; // K = 1000
    this._fps = maxFps;
    this._speed = 1;
    this._head = null;
    this._tail = null;
  }
}

Engine extends Clock and manages the main loop. The tick method selection is resolved once at module load time:

// src/engine/engine.js
const engineTickMethod = /*#__PURE__*/ (() =>
  isBrowser ? requestAnimationFrame : setImmediate
)();

That /*#__PURE__*/ annotation is deliberate — it tells bundlers the IIFE has no side effects, so it can be tree-shaken. In Node.js, it falls back to setImmediate to keep tests deterministic.

The main loop in Engine.update() walks the linked list of active tickables:

let activeTickable = this._head;
while (activeTickable) {
  const nextTickable = activeTickable._next;
  if (!activeTickable.paused) {
    tick(activeTickable, (time - activeTickable._startTime) * activeTickable._speed * engineSpeed, ...);
  } else {
    removeChild(this, activeTickable);
    activeTickable._running = false;
    if (activeTickable.completed && !activeTickable._cancelled) {
      activeTickable.cancel();
    }
  }
  activeTickable = nextTickable;
}

The linked-list walk in src/core/helpers.js uses a generic addChild / removeChild pair that accepts custom prevProp and nextProp string arguments. The same pair handles the engine's top-level tickable list and the tween-level additive composition list, avoiding a second data structure.

The render function in src/core/render.js is where the math lives. Looping, alternation, and easing are computed per-tick. One detail worth noting: for iteration counting it uses bitwise NOT (~~) instead of Math.floor():

// src/core/render.js
const currentIteration = ~~(tickableCurrentTime / (iterationDuration + ...));

This is a micro-optimization that shows up in performance-critical loops — ~~x is consistently faster than Math.floor(x) for positive numbers across browser JS engines.

Additive composition in src/animation/additive.js handles overlapping animations on the same property. When two animations target the same CSS property simultaneously, instead of the last one winning (replace mode), blend mode walks the tween linked list tail-to-head and sums _number values:

let tween = tweens._tail;
while (tween && tween !== lookupTween) {
  additiveValue += tween._number;
  tween = tween._prevAdd;
}
lookupTween._toNumber = additiveValue;

That _prevAdd pointer is a separate linked list threaded through the same tween objects — so each tween participates in both the time-ordered list and the additive composition list without extra allocations.

The library also ships a WAAPI bridge in src/waapi/waapi.js. This delegates animations to the browser's native Web Animations API where available, with Anime.js handling the parts WAAPI doesn't cover (staggering, JS object targets, custom easings).

The src/core/consts.js enum pattern uses numeric values and symbolic keys:

export const compositionTypes = {
  replace: 0,
  none: 1,
  blend: 2,
}

No TypeScript enums, no string unions — just objects with numeric values. The types live in a separate types/ directory as JSDoc-only imports. The codebase is typed entirely through JSDoc annotations, which means the source stays valid JS while still producing .d.ts declaration files via tsc.

Using it

The v4 API is ESM-first. Named imports, no default export:

import { animate, stagger, createTimeline } from 'animejs';

// Animate elements with staggered delay
animate('.card', {
  x: [0, 240],
  opacity: [0, 1],
  duration: 800,
  delay: stagger(60, { from: 'center' }),
  ease: 'inOutQuart',
  loop: 2,
  alternate: true,
});

// Timeline with relative positions
const tl = createTimeline();
tl.add('.title', { opacity: [0, 1], duration: 400 })
  .add('.subtitle', { y: [20, 0], opacity: [0, 1] }, '-=200'); // starts 200ms before previous ends

The modular exports mean you can import only what you need: animejs/timer, animejs/easings/spring, animejs/svg. The bundle is tree-shakeable down to the primitives you actually use.

Rough edges

The test setup is browser-first. test:browser starts a browser-sync server and runs tests visually — there's a test:node script, but it only covers a subset. If you want to run the full test suite headlessly in CI, you're on your own.

The docs live entirely on animejs.com — there's no offline doc, no /docs in the repo. If that site goes down or lags the source, you're reading source. For v4, this happened: the README points to the docs site, but some of the API surface (like the Scope or Draggable modules) is sparsely documented there.

V3 to V4 is a meaningful breaking change. The anime() default export is gone. If you have any v3 integrations, budget time for the migration.

Bottom line

Anime.js is the right choice when you want an animation engine you can actually read and extend — not a black box. The architecture is clean, the source is well-annotated, and the additive composition system handles overlapping animations in a way that most libraries paper over with "last wins" semantics.

juliangarnier/anime on GitHub
juliangarnier/anime