DEV Community

Sean
Sean

Posted on

How I Built a Wobble GIF Maker with Canvas API and Next.js

A still image only needs a few well-chosen pixels to move before it starts to feel alive. That idea became Purupuru Maker, a free browser tool for turning images into wobbly GIFs and videos.

The editor lets you paint the part of an image that should move, choose one of six motion presets, tune the feel, and export the result. The important constraint was privacy: the source image should never need to leave the browser.

This post walks through the actual architecture: a Canvas-based mask editor, a deformable WebGL mesh, spring motion, local GIF/video encoding, and a small SVG trick used on the landing page.

The browser-only architecture

Purupuru Maker is built with Next.js 15 and React 19. Next.js handles routing, localized pages, metadata, and the surrounding product UI. The animation studio is a client component because it depends on browser primitives:

  • HTML Canvas for painting masks, compositing frames, and capture
  • Pointer Events for mouse, pen, and touch input
  • Three.js/WebGL for the deformable image mesh
  • requestAnimationFrame for live preview
  • MediaRecorder and canvas.captureStream() for video
  • gifenc for GIF quantization and encoding

When a user chooses a file, the editor creates a local object URL and decodes it into an HTMLImageElement. There is no upload request in this path. The image, mask, preview frames, and exported Blob all stay on the device.

1. Paint motion as an alpha mask

The first design decision was to separate “what moves” from “how it moves.” A hidden canvas stores the painted mask. Its alpha channel is the motion weight: transparent pixels remain anchored; opaque pixels move most.

The same pointer-event code works for a mouse, stylus, or finger. Painting and erasing are just two compositing modes:

context.globalCompositeOperation =
  tool === "erase" ? "destination-out" : "source-over";
context.strokeStyle = "rgba(238, 120, 104, 0.65)";
context.lineWidth = brushSize;
context.lineCap = "round";
context.lineJoin = "round";
context.moveTo(previous.x, previous.y);
context.lineTo(point.x, point.y);
context.stroke();
Enter fullscreen mode Exit fullscreen mode

Keeping the mask independent means a user can switch from Jelly to Sway without repainting anything. Undo/redo stores recent ImageData snapshots, while batch items can keep their own masks.

2. Convert the mask into mesh weights efficiently

The image is divided into a regular triangle grid: 64×64 cells on desktop and 44×44 on mobile. Alternating each cell's diagonal avoids a visible directional bias.

Reading the canvas once is important. Calling getImageData() for every vertex would create thousands of expensive readbacks, so the renderer reads the mask once and builds a summed-area table. Each vertex can then average a small neighborhood with four array lookups.

After sampling, I run three smoothing passes and fade weights near the outer edge. The result is a soft deformation field instead of a hard cut between moving and fixed pixels. That soft boundary is what keeps pudding, hair, fabric, and cheeks from looking detached from the source image.

3. Deform a textured WebGL mesh

The source image becomes a Three.js CanvasTexture on a BufferGeometry mesh. UV coordinates stay fixed; only vertex positions change. An orthographic camera makes WebGL coordinates line up with the source image's pixel dimensions.

For every preview frame, the engine:

  1. calculates the target offset for each weighted vertex;
  2. integrates a spring-damper response toward that target;
  3. clamps the offset to prevent broken triangles;
  4. updates the dynamic position buffer;
  5. renders the mesh and composites its canvas into the visible 2D canvas.

The spring step is deliberately small and stable:

const stableDt = Math.min(dt, 1 / 30);
velocity += (target - offset) * stiffness * stableDt;
velocity *= Math.exp(-damping * stableDt);
offset += velocity * stableDt;
Enter fullscreen mode Exit fullscreen mode

Clamping delta time matters when a background tab wakes up. Without it, one giant frame can inject enough energy to explode the mesh.

4. Six presets, one physics system

The six presets are different target fields feeding the same spring integration:

  • Jelly combines two-axis sine waves for soft, rounded motion.
  • Sway increases horizontal movement toward the lower end of a shape, which works well for hair, ribbons, and fabric.
  • Bounce uses a clear vertical beat and a little width change.
  • Wave varies phase across the grid so motion travels through the image.
  • Pulse pushes vertices away from or toward the image center.
  • Quake combines higher-frequency waves for a nervous or comic shake.

Speed controls phase progression. Amplitude determines the target size. Damping changes how closely the mesh follows the target, and gravity adds a directional bias. On supported mobile devices, DeviceMotion can add a decaying shake impulse too.

This arrangement made the editor easier to extend: a new preset only needs to describe targetX and targetY. Mask sampling, spring behavior, rendering, and export do not change.

5. A separate SVG cut-out trick for the landing page

The production editor uses the weighted mesh, but the landing page needed an immediate, lightweight preview before anyone opens the studio.

For that hero animation, I reuse the same courier image several times inside one SVG. clipPath shapes isolate the hair, ribbon, and scarf. Each clipped image sits in its own group, and CSS applies a small transform animation to that group.

<g className="hero-motion__ribbon">
  <image
    clipPath="url(#ribbon-clip)"
    href="/courier.webp"
    width="1024"
    height="1024"
  />
</g>
Enter fullscreen mode Exit fullscreen mode

It is intentionally not pretending to be the full editor. It is a cheap, crisp marketing preview that demonstrates selective motion, while the studio uses the general-purpose mask-and-mesh engine.

6. Export GIF without a server

GIF export dynamically loads gifenc, renders 24 frames at 20 fps, and runs the physics at 60 Hz between output frames. A 0.6-second warm-up lets the spring settle before capture, so the saved loop matches the live preview instead of starting from a stiff rest pose.

One surprising detail was palette stability. Generating a new palette for every frame made stationary pixels shimmer. Purupuru Maker quantizes the first frame into one 256-color palette and reuses it for the whole animation. That keeps fixed areas byte-stable and makes the wobble look much cleaner.

The encoder returns bytes that become an image/gif Blob and a local download URL. No rendered frame is sent to an API.

7. Record WebM or MP4 from the same canvas

Video export uses the browser's own media pipeline:

const stream = canvas.captureStream(30);
const recorder = new MediaRecorder(stream, {
  mimeType,
  videoBitsPerSecond: 4_000_000,
});
Enter fullscreen mode Exit fullscreen mode

The app checks MediaRecorder.isTypeSupported() before showing a format as available. WebM candidates try VP9, then VP8, then generic WebM. MP4 is enabled only when the browser exposes a compatible MP4 recorder.

The recorder captures the exact canvas used for preview for about 2.2 seconds, collects chunks, creates a Blob, and stops every stream track. If MP4 is not supported, the user can still export WebM without repainting the mask.

What local-first changed

“No upload” was not only a privacy line. It simplified the core product:

  • no object storage for private images;
  • no render queue or job status;
  • no account needed for the editor;
  • no server bill for every exported frame;
  • instant iteration after every brush stroke.

The tradeoff is that performance depends on the device. The editor limits the working stage to 900×680 pixels, uses a smaller mesh on mobile, caps pixel ratio at 2, and downsizes GIF output to a maximum dimension of 520 pixels.

Lessons from building it

A few implementation choices had an outsized effect:

  1. Separate selection from motion. A reusable mask makes preset exploration instant.
  2. Read canvas pixels once. The summed-area table turned mask sampling from a bottleneck into cheap array math.
  3. Use a spring after the waveform. Pure sine displacement looks mechanical; spring response adds material feel.
  4. Use one GIF palette. Stable colors matter as much as moving vertices.
  5. Treat SVG and WebGL as different tools. SVG cut-outs are ideal for a controlled hero demo; a mesh is better for arbitrary user images.
  6. Detect codecs instead of assuming them. Browser-side MP4 support still varies.

Purupuru Maker now includes 10 original demos, six presets, GIF/MP4/WebM export, and interfaces in English, Japanese, and Chinese.

You can try it here: purupurumaker.me. I would love to hear which preset feels best—and what other motion style you would add.

Top comments (1)

Collapse
 
frank_signorini profile image
Frank

This is super cool! I'm curious if you considered using Web Workers for the GIF encoding to keep