A fast, dependency-free Lottie engine for every JavaScript runtime and environment. No DOM, no native code, and no eval, so it runs under the strictest Content Security Policy.

$ npm install lottie.js

lottie.js parses a Lottie (Bodymovin, After Effects export) document into a renderer-agnostic scene and draws that scene to Canvas2D, SVG, or raw pixels. It has no runtime dependencies and makes no assumption about its host: no node: modules, no DOM, no I/O in the core. It runs in the browser, on a server with Node.js, in React Native, Deno, Bun, and Web Workers, and it renders animations to PNG on a server without a headless browser.

How it works

Three stages, one pipeline:

1

Parse. parse(json) returns an immutable Animation with its metadata. Synchronous, no I/O; load(src) fetches URLs.

2

Evaluate. anim.sceneAt(frame) turns a frame into a scene: plain draw operations, independent of the environment.

3

Render. A surface draws the scene: native Canvas2D calls, an SVG string, or RGBA pixels and PNG in pure JavaScript.

Playback drives time on top. Nothing above the surface touches the DOM, so the same code runs in the browser, on a server, or in a worker.

Quick start

Pick a runtime, an install method, and a framework; the snippets adjust. ESM and CommonJS are both supported.

Runtime
Install
Framework

Surfaces

A surface is a drawing target of one kind. It holds its own state, so one Animation can feed several surfaces at once. Every surface has render(anim, frame?, options?) and dispose().

SurfaceOutputUse for
CanvasSurfaceCanvas2D draw calls The fast path for playback in the browser. Respects the context transform, so you can tile it.
SvgSurfaceSVG string Static output and image/svg+xml responses. idPrefix keeps ids unique across several SVGs on a page.
ImageSurfaceRGBA pixels, PNG bytes Server-side rendering with no native code and no browser, and workers.

Render options are width, height, dpr, clear (Canvas), idPrefix (SVG), and images (ImageSurface, decoded pixels for image assets keyed by asset id). Dimensions default to the composition size.

Playback

Playback owns time, looping, speed, and direction, and emits events. It has no built-in clock, so it runs anywhere: tick(dtMs) is the primitive, and start() is an optional requestAnimationFrame loop for the browser.

import { Playback } from 'lottie.js';

const player = new Playback({
  animation: anim,
  surface,
  loop: true,
  speed: 1,
  mode: 'forward',          // 'forward' | 'reverse' | 'bounce'
  render: { width: 512, height: 512 },
});

player.play();
player.pause();
player.seek(30);
player.seekTime(1.5);

const off = player.on('frame', ({ frame, progress }) => {});
player.on('loop', () => {});
player.on('complete', () => {});
await player.finished;

player.destroy();           // stops, unsubscribes, disposes the surface

Drive it by hand where there is no animation frame callback:

player.tick(16);            // advance ~16 ms

Options also include segment to restrict the frame range, autoplay, and respectReducedMotion for users who prefer reduced motion. Events: frame, loop, complete, error.

Recipes

Next.js route handler

Serve rendered frames from an API route:

// app/api/frame/route.js
import { parse, ImageSurface } from 'lottie.js';
import animation from './animation.json';

export async function GET() {
  const png = await new ImageSurface(512, 512).png(parse(animation), 30);
  return new Response(png, { headers: { 'Content-Type': 'image/png' } });
}

Custom renderers

Where there is no Canvas2D or DOM (React Native with Skia, WebGL, a native backend), read the scene and issue your own draw calls:

const anim = parse(data);
for (const op of anim.sceneAt(frame).ops) {
  // op.kind   : 'shape' | 'image'
  // op.matrix : [a, b, c, d, tx, ty]
  // op.paths  : cubic-bezier contours
  // op.fills  : solid colors and gradients
  // op.strokes: color or gradient, width, cap, join, dashes
  // op.clips  : optional mask/matte stages (intersect or subtract)
  // op.blend  : optional Lottie blend mode
}

Cookbook

Real tasks, ready to paste. Each one normally pulls in a headless browser, a native module, or a heavyweight player; with lottie.js it is a few lines of plain JavaScript. And none of it is theoretical: every demo below runs live on this page, importing lottie.js straight from jsDelivr, exactly like the CDN snippet in Quick start.

The engine, live. This stage is lottie.js rendering a Lottie document in your browser right now: play it, scrub it, switch the surface, or drop your own .json / Telegram .tgs onto it.

0 / 90
Surface

No player build, no plugins: one ES module doing the drawing.

Open Graph previews in Next.js. Serve a rendered frame as the social preview image. The usual answer is Puppeteer on a serverless function; ImageSurface is plain JavaScript, so the whole route deploys anywhere with no binaries.

live Same code, run by your browser at half size.
// app/og/route.js
import { parse, ImageSurface } from 'lottie.js';
import animation from './hero.json';

export async function GET() {
  const png = await new ImageSurface(1200, 630).png(parse(animation), 45);
  return new Response(png, {
    headers: {
      'Content-Type': 'image/png',
      'Cache-Control': 'public, max-age=86400',
    },
  });
}

Telegram .tgs sticker to a static image. A .tgs file is gzipped Lottie JSON. Decompress it with the built-in DecompressionStream, then render any frame. Every modern runtime ships the decompressor, so a sticker pipeline needs zero extra dependencies.

live Any Telegram sticker works.
import { parse, ImageSurface } from 'lottie.js';

const res = await fetch('sticker.tgs');
const json = await new Response(
  res.body.pipeThrough(new DecompressionStream('gzip'))
).text();

const anim = parse(json);
const png = await new ImageSurface(512, 512).png(anim, 0);

Scroll-driven animation. Map scroll progress to a frame with frameAtProgress. No timeline library and no playback loop: one render call per scroll event, driven by a number you already have.

live

Scroll me.

The box maps its scroll position to a frame with frameAtProgress, exactly like the snippet below does for the page.

Keep going…

…and back up rewinds it.

No timers, no playback loop: one render call per scroll event.

That's the whole recipe.

const surface = new CanvasSurface(canvas.getContext('2d'));

addEventListener('scroll', () => {
  const t = scrollY / (document.body.scrollHeight - innerHeight);
  surface.render(anim, anim.frameAtProgress(t));
}, { passive: true });

Play on hover. Animated icons that run only while the pointer is over them. With autoplay: false nothing renders until the pointer arrives, so a page full of icons stays cheap.

live Plays while the pointer is over it, pauses the moment it leaves.
const player = new Playback({ animation: anim, surface, loop: true, autoplay: false });

button.addEventListener('mouseenter', () => player.play());
button.addEventListener('mouseleave', () => player.pause());

Visual regression tests. ImageSurface output is deterministic, byte-identical on every platform, so animation snapshots do not flake. The raster is pure JS: the same bytes on macOS, Linux, and CI, with no browser in the test suite.

live
import { createHash } from 'node:crypto';

test('hero animation frame 30 is stable', async () => {
  const { data } = new ImageSurface(256, 256).render(parse(json), 30);
  const hash = createHash('sha256').update(data).digest('hex');
  expect(hash).toMatchSnapshot();
});

API reference

Every export in the package, with signatures. TypeScript declarations ship in the box, so all of this autocompletes.

parse(source) → Animation

Parse a document into an Animation. Synchronous, no I/O. source is a parsed object, a JSON string, or UTF-8 bytes, so it takes the output of fetch, readFile, or a bundler JSON import as-is.

load(source, options?) → Promise<Animation>

The asynchronous form: fetches a URL (string or URL), otherwise parses directly. Options: fetch to inject a custom fetch, signal to abort.

anim.sceneAt(frame?) → Scene

Animation is read-only: name, version, width, height, frameRate, inPoint, outPoint, totalFrames, duration, markers. Three methods map time to frames and frames to scenes:

MethodReturns
frameAtTime(seconds)The frame for a wall-clock time, looped over the duration.
frameAtProgress(t)The frame for progress 0..1: scroll position, a slider, a test point.
sceneAt(frame?)The evaluated Scene: flat draw ops for custom renderers.
new CanvasSurface(ctx) · new SvgSurface() · new ImageSurface(width?, height?)

Every surface has render(anim, frame?, options?) and dispose(); ImageSurface adds png(anim, frame?, options?) returning PNG bytes. Render options and trade-offs are covered in Surfaces. encodePNG(rgba, width, height), the pure-JS PNG encoder, is exported on its own.

new Playback(options) · player.tick(dtMs) · player.on(type, cb)
OptionDefaultMeaning
animation, surfacerequiredWhat to play and where to draw it.
loopfalseStart over when the end is reached.
speed1Rate multiplier.
mode'forward''forward', 'reverse', or 'bounce'.
segmentfull range[from, to] frame window to play.
autoplayfalseCall start() immediately.
respectReducedMotionfalseHold the first frame for users who prefer reduced motion.
render{}Options forwarded to every surface.render call.

Methods: play(), pause(), seek(frame), seekTime(seconds), tick(dtMs), start(), stop(), destroy(). Properties: frame, playing, progress, finished (a promise). Events: frame, loop, complete, error; on() returns an unsubscribe function.

mount(options) → Promise<Playback>

Browser convenience: wires a CanvasSurface and a Playback and starts playing. Options: canvas, src or animation, plus any Playback option.

setExpressionEvaluator(fn)

Plug in a Lottie expressions engine. The core ships no interpreter and stays eval-free; without an evaluator, expressions fall back to keyframed values.

Supported features

Supported across every surface:

  • shape layers: paths, ellipses, rectangles, polystars
  • trim paths, both modes
  • repeaters
  • rounded corners, zig zag, pucker and bloat, twist
  • offset paths with real joins
  • merge paths: fills compile to clip stages, stroked shapes get true boolean outlines
  • gradient fills and strokes, linear and radial with highlight
  • strokes with caps, joins, miter limits, and dashes
  • masks: all seven modes plus opacity and expansion
  • track mattes: alpha and luminance, both invertible
  • all blend modes
  • motion blur
  • text layers from embedded glyphs: layout, justification, tracking, boxed text, document keyframes
  • text animators with range selectors, text on a path
  • image layers
  • slots
  • precompositions with bounds clipping and collapse transform
  • time remapping, time stretch
  • full transform stack with layer parenting and auto-orient
  • solid layers
  • bezier easing, per-dimension easing, hold and spatial keyframes

Image layers resolve to data URIs or URLs. CanvasSurface and SvgSurface load them natively; for ImageSurface, pass decoded pixels via render(anim, frame, { images }).

Not yet: layer effects, font-file text without embedded glyphs, and 3D layers.