Turning static pixels into self-drawing SVG animations without melting the browser.
We want to take a plain JPG and make it draw itself on the screen, as if an invisible pen is tracing its outlines in real time.
There is just one obvious problem: you cannot animate pixels. Because pixels are a grid of colored dots without start or end points, achieving a drawing effect requires continuous lines. To solve this, we must first convert the image into SVG paths and then animate those paths.
Can We Just Do Everything in the Browser?
Attempt 1: The Client-Side Monolith
Our first instinct might be to use a library like potrace to trace the image, paired with sharp to convert it to high-contrast black-and-white so the tracer has clean edges. We can install the JavaScript ports of these libraries:
import { trace } from 'potrace-js'
import sharp from 'sharp-wasm'
And then wire them up to a file upload input in React:
async function handleUpload(file) {
const buffer = await file.arrayBuffer()
const bwImage = await sharp(buffer).threshold(120).toBuffer()
const svgLines = await trace(bwImage)
setSvg(svgLines)
}
Unfortunately, this approach fails on the web. The potrace-js package hasn't been updated in nearly a decade, and the WebAssembly port of Sharp explicitly states it is unsupported in browsers.
Attempt 2: The Unoptimized SVG
Since client-side processing isn’t viable, we move the image conversion to the server. When the user uploads an image, the server processes it using the native Node.js versions of sharp and potrace, then sends back the final SVG string. With the heavy lifting moved off the client, we can now install Anime.js:
import { animate, svg } from 'animejs'
We select all the generated paths and instruct Anime.js to draw them:
const paths = document.querySelectorAll('path')
const drawables = svg.createDrawable(paths)
animate(drawables, {
draw: ['0 0', '0 1'],
duration: 2000
})
However, this doesn’t work perfectly out of the box.
When Anime.js runs, nothing draws because Potrace outputs paths with a fill but no stroke.
Furthermore, these massive, unoptimized paths instantly choke the browser and cause the frame rate to plummet.
We must optimize the SVG before attempting to animate it.
How Do We Separate the Math from the Magic?
On the server, we need to configure potrace and sharp correctly. Rather than thresholding the image to pure black and white in sharp (which destroys the gradient data potrace needs to find clean edges), we instead prepare a high-contrast grayscale image by collapsing the color and stretching the contrast:
// app/services/potrace-service.ts
import sharp from "sharp"
import potrace from "potrace"
Inside our handler, we process the buffer to stretch the contrast before tracing:
async function handleUpload(fileBuffer) {
const preprocessed = await sharp(fileBuffer)
.grayscale() // collapse color to intensity
.normalize() // stretch contrast (1st-99th percentile)
.png()
.toBuffer()
return new Promise((resolve, reject) => {
potrace.trace(
preprocessed,
{ threshold: 120, optCurve: true, turdSize: 2 },
(err, svg) => err ? reject(err) : resolve(svg)
)
})
}
The actual binarization happens inside potrace using the threshold parameter. By passing optCurve: true to enable Bezier curve fitting, we smooth out jagged edges and significantly reduce the mathematical complexity of the path. The server now returns a tiny, optimized SVG that the browser can render instantly.
Before animating on the client, we must address the stroke issue. Since Potrace outputs SVG paths with a fill but no stroke, any stroke animation would be invisible.
We solve this by copying the fill color to the stroke and making the fill transparent before starting the animation:
// app/components/svg-player.tsx
import { animate, svg, stagger } from 'animejs'
We extract every path element from the DOM:
const paths = document.querySelectorAll('path')
Then we iterate over the paths to convert their fill colors into strokes:
paths.forEach(path => {
const fill = path.getAttribute('fill') || '#000000'
path.setAttribute('stroke', fill)
path.style.strokeWidth = '2px' // You will need to tune this based on your viewBox scale
path.style.fill = 'transparent'
})
Finally, we configure the drawables and trigger the stagger animation:
const drawables = svg.createDrawable(paths)
animate(drawables, {
draw: ['0 0', '0 1'],
duration: 2000,
ease: 'inOutSine',
delay: stagger(100)
})
With these optimizations in place, the animation runs smoothly without dropping frames.
How SVG Drawing Animation Works
Anime.js achieves the drawing effect by manipulating two CSS properties: stroke-dasharray and stroke-dashoffset.
The stroke-dasharray property breaks the stroke into a pattern of dashes and gaps. By setting the dash length to match the total length of the entire path, we create a single dash that covers the full path, followed by a gap of equal length.
The stroke-dashoffset property shifts the starting point of the dash array. When we set the offset equal to the path's total length, the visible dash is pushed entirely out of view, leaving only the gap. This makes the path invisible.
To animate the drawing, we simply transition stroke-dashoffset from the total length down to zero. As the offset decreases, the visible stroke slides into place.
Does the Browser Measure Every Path?
In the past, Anime.js had to call getTotalLength() for every single path, which is an expensive native geometry calculation. Anime.js v4 improves this by setting a native SVG attribute pathLength="1000" on every path. This tricks the browser into pretending every path is exactly 1000 units long, allowing the library to compute the dash offsets using simple arithmetic against that constant without ever calling getTotalLength().
While Anime.js’s math doesn’t care about path complexity, the browser’s renderer still does, which is why using optCurve to keep the d string small remains critical.
Can We Do This With Any Image?
Potrace is a monochrome bitmap tracer that posterizes the image to two tones and traces the boundary. While it produces a beautiful tracing effect for clean logos or line art, feeding it a detailed photograph results in a blobby, unrecognizable mess. This technique is strictly intended for simple graphics.
A noisy or photo-heavy image still comes back as a single <path>, but that one path's d attribute can balloon into a massive string, packed with a subpath for every fleck of noise Potrace picked up. The browser has to parse and repaint one huge compound path every frame.
What would full color take? The stroke-dashoffset trick only animates outlines. True multicolor tracing requires a different tool like vtracer. Instead of one massive path, vtracer produces multiple
Why Architecture Matters for Animations
It is tempting to throw every library into the browser bundle, but this architecture comes with strict constraints. Sharp requires prebuilt native C++ bindings for the operating system it runs on. You cannot deploy this to pure edge runtimes like Cloudflare Workers. It requires a full Node server.
When building an interactive UI with sliders, every drag tick sends a heavy image processing request to the server. You need to debounce these sliders and use an AbortController to cancel superseded requests in-flight.
let abortController = new AbortController()
To prevent race conditions and redundant processing, we cancel any pending request before starting a new one:
async function handleSliderChange(settings) {
abortController.abort() // Cancel the previous request
abortController = new AbortController() // Create a new token for this request
await fetch('/api/convert', {
method: 'POST',
body: JSON.stringify(settings),
signal: abortController.signal
})
}
By offloading the heavy image processing to the server and implementing strict request cancellation, we ensure the client stays responsive and fluid.
Lessons Learned
-
Potrace outputs a single path. By default, the standard trace output is a single
element, regardless of the source image's visual complexity. The geometry is entirely contained within the d attribute. - Anime.js v4 renamed easing to ease. Be aware of this if referencing older v3 tutorials.
- Anime.js v4 misparses the transparent keyword. The color parser only recognizes RGB, HEX, and HSL formats, defaulting transparent to opaque black. To reveal a solid shape post-animation, animate fillOpacity instead of the color.
- The @types/potrace package has a typo. The type definitions include a turdPolicy option, but the JavaScript library expects turnPolicy at runtime. You must bypass the TypeScript type-checking for this configuration object.
The Shippable Artifact
The tool doesn't just preview the animation. It features a Download Animation (HTML) export that gives you a fully standalone HTML file. It has the SVG inlined, the CSS embedded directly in a <style> tag, and a <script type="module"> that loads Anime.js from the unpkg CDN. It's a zero-dependency way to share animated SVGs.
As a fun easter egg, the website uses its own exported trace as the background. The animated wireframe drawing behind the homepage is the exact same optimized SVG we just built.
Links
- Live Demo: https://img2svg-animation.vercel.app/
- GitHub Repo: https://github.com/a1stok/img2svg-animation
- Anime.js: https://animejs.com/
- Potrace: https://www.npmjs.com/package/potrace



Top comments (0)