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, APNG,
JPEG, and GIF 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, APNG, JPEG, or GIF bytes 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, JPEG, APNG, GIF bytes | Server-side rendering with no native code and no browser, and workers. |
One-shot output is a function: png, jpeg,
and svg take a frame, apng and gif
take the timeline, and all five are async. The raster four also exist as
ImageSurface methods for rendering many files from one
configured surface.
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.filters: optional layer effects still to run
// 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 also carries the file
functions below as methods, bound to its default size. Render options and
trade-offs are covered in Surfaces.
One-shot file output; all five return promises. svg
resolves to markup, the rest to bytes. JPEG has no alpha channel, so
transparent pixels land on background (white unless you say
otherwise) and quality picks the trade-off; the animated
pair adds fps, segment, and loops.
The bare encoders — encodePNG, encodeJPEG,
encodeAPNG, encodeGIF — and
applyColorFilters for custom renderers are exported on their
own.
Resolves the animation's image assets — data URIs and fetched URLs —
into the decoded-pixels map ImageSurface takes, skipping
what it cannot decode. decodePNG(bytes) decodes a single
PNG on its own (8- and 16-bit; grayscale, palette, RGB and RGBA;
tRNS) through the platform's
DecompressionStream, so the whole path stays
dependency-free. Interlaced files are rejected.
| 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
- layer effects: tint, fill, tritone, pro levels, gaussian blur, drop shadow
- 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 }) — or let
decodeImages build that map for you.
Not yet: the remaining layer effects (stroke, radial wipe, displacement map, set matte), 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.