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:
Parse. parse(json) returns an
immutable Animation with its metadata. Synchronous, no
I/O; load(src) fetches URLs.
Evaluate. anim.sceneAt(frame) turns
a frame into a scene: plain draw operations, independent of the
environment.
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.
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().
| Surface | Output | Use for |
|---|---|---|
CanvasSurface | Canvas2D draw calls | The fast path for playback in the browser. Respects the context transform, so you can tile it. |
SvgSurface | SVG string | Static output and image/svg+xml responses.
idPrefix keeps ids unique across several SVGs on a
page. |
ImageSurface | RGBA 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 surfaceDrive it by hand where there is no animation frame callback:
player.tick(16); // advance ~16 msOptions 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.
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.
// 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.
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.
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.
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.
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 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.
The asynchronous form: fetches a URL (string or URL),
otherwise parses directly. Options: fetch to inject a custom
fetch, signal to abort.
Animation is read-only: name,
version, width, height,
frameRate, inPoint, outPoint,
totalFrames, duration, markers.
Three methods map time to frames and frames to scenes:
| Method | Returns |
|---|---|
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. |
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.
| Option | Default | Meaning |
|---|---|---|
animation, surface | required | What to play and where to draw it. |
loop | false | Start over when the end is reached. |
speed | 1 | Rate multiplier. |
mode | 'forward' | 'forward', 'reverse', or 'bounce'. |
segment | full range | [from, to] frame window to play. |
autoplay | false | Call start() immediately. |
respectReducedMotion | false | Hold 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.
Browser convenience: wires a CanvasSurface and a
Playback and starts playing. Options: canvas,
src or animation, plus any Playback
option.
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.
Free and MIT-licensed, forever. If it renders in your product, sponsoring keeps it maintained and moving.