Dev.to WebDev 🛠 Dev 👁 0 📖 6 min read

Building a 3D Heart Locket GIF Maker with Canvas and Web Workers

The "my beloved" heart locket GIF has been a staple reaction format since 2020: a glossy red locket swings open to show a photo on one side and a caption on the other. I built a heart locket GIF maker that generates thes

The "my beloved" heart locket GIF has been a staple reaction format since 2020: a glossy red locket swings open to show a photo on one side and a caption on the other. I built a heart locket GIF maker that generates these entirely in the browser — no upload, no server-side rendering.

Disclosure: that's my own project. Everything below works with plain Canvas and a GIF encoder; nothing here depends on it.

The interesting parts turned out to be the ones I didn't expect: GIF's one-bit transparency, palette drift across frames, and deciding what belongs on a worker and what doesn't.

The shape: a heart as a clipping path

No image assets. The locket is drawn in code, which means it scales to any output size and recolours without a sprite sheet.

A heart is two bezier curves meeting at a point:

function heartPath(ctx, cx, cy, width, height) {
  const top = height * 0.3;
  ctx.beginPath();
  ctx.moveTo(cx, cy + top);
  ctx.bezierCurveTo(cx, cy, cx - width / 2, cy, cx - width / 2, cy + top);
  ctx.bezierCurveTo(
    cx - width / 2, cy + (height + top) / 2,
    cx, cy + (height + top) / 2,
    cx, cy + height
  );
  ctx.bezierCurveTo(
    cx, cy + (height + top) / 2,
    cx + width / 2, cy + (height + top) / 2,
    cx + width / 2, cy + top
  );
  ctx.bezierCurveTo(cx + width / 2, cy, cx, cy, cx, cy + top);
  ctx.closePath();
}

Once you have the path, fitting a user's photo inside it is just clip():

ctx.save();
heartPath(ctx, cx, cy, w, h);
ctx.clip();
ctx.drawImage(photo, dx, dy, dw, dh);
ctx.restore();

One detail that matters for real photos: a heart is wide at the top and narrow at the bottom, so centring an image inside it crops faces badly. The default placement pushes the image up slightly so the subject sits in the wide half.

The animation: faking a hinge

The locket opens like a book. A real 3D rotation would mean a transform matrix with perspective, but on a 2D canvas you can get a convincing swing by scaling the lid horizontally as it turns:

function drawLid(ctx, t, hingeX) {
  const angle = easeOutCubic(t) * Math.PI;   // 0 → 180°
  const scaleX = Math.cos(angle);            // 1 → 0 → -1

  ctx.save();
  ctx.translate(hingeX, 0);
  ctx.scale(scaleX, 1);
  ctx.translate(-hingeX, 0);
  drawLidFace(ctx, scaleX < 0);              // back face past 90°
  ctx.restore();
}

When scaleX crosses zero the lid is edge-on, and past that you're seeing its inside face — so you swap which face you draw. Shading the back face slightly darker sells the illusion for about 12 lines of code.

The easing matters more than the geometry. A linear open looks mechanical; easeOutCubic gives it the weight of a hinge.

Frame generation, and knowing when to stop

Sixty frames at 480×360 is a lot of pixel data to hold in memory. But most of those frames are identical — the locket sits open and motionless for the last third of the loop.

So after drawing each frame, compare it to the previous one. If nothing changed, extend the previous frame's delay instead of pushing a new frame:

if (prev && sameBytes(prev, pixels)) {
  delays[delays.length - 1] += FRAME_MS;
} else {
  frames.push(pixels);
  delays.push(FRAME_MS);
  prev = pixels;
}

In practice this takes 60 drawn frames down to about 46 written ones, which is roughly a 20% smaller file for identical playback.

Encoding: what goes on the worker

GIF encoding is the expensive part. Building a 256-colour palette and mapping every pixel to it blocks the main thread long enough to freeze the UI, so that work goes to a worker:

// encode.worker.js
import { GIFEncoder, quantize, applyPalette } from 'gifenc';

self.onmessage = ({ data: { frames, width, height, delays } }) => {
  const gif = GIFEncoder();

  // Sample across the whole animation, not just frame 0
  const sample = buildSample(frames);
  const palette = quantize(sample, 256);

  frames.forEach((pixels, i) => {
    const indexed = applyPalette(pixels, palette);
    gif.writeFrame(indexed, width, height, {
      palette: i === 0 ? palette : undefined,
      delay: delays[i],
    });
  });

  gif.finish();
  const bytes = gif.bytes();
  self.postMessage(bytes, [bytes.buffer]);
};

Two things I got wrong the first time:

Quantising from frame 0 only. The first frame is a closed locket — mostly red and gold, with none of the photo visible. Building the palette from it meant skin tones got mapped to whatever was closest in a palette that had never seen them. Sampling pixels across the full set fixes it.

Transferring instead of copying. postMessage(bytes) structured-clones a multi-megabyte buffer. Passing [bytes.buffer] as the transfer list hands over ownership instead, which is effectively free.

What I deliberately left on the main thread

Drawing each frame stays on the main thread. I'd assumed it should move to the worker too, but measuring first showed a single frame draw never blocks for more than ~17ms — one animation frame. Moving it would have meant OffscreenCanvas plus a fallback path plus shuttling ImageData around, for no perceptible gain.

Measure before you parallelise.

The GIF transparency trap

This is the one that costs you an afternoon. GIF has no alpha channel. It has a single palette index designated as "transparent" — a pixel is either fully visible or fully invisible, with nothing in between.

PNG gives you 256 levels of alpha, which is what makes anti-aliased edges look smooth. GIF gives you one bit. So the curved edge of a heart, which looks clean in the canvas preview, comes out visibly stair-stepped in the exported GIF.

There's no way to fix that inside the format. What you can do is choose your compromise:

  • Matte against a known colour. If you know the GIF will sit on a white chat background, composite the edge against white before quantising. The edges look smooth, but only on white.
  • Pick the threshold carefully. Everything above ~50% alpha becomes opaque, everything below becomes transparent. Shifting that cut-off trades a chunky outline for a chewed-up one.
  • Offer a solid background option. Often the honest answer. Most people posting to Discord or WhatsApp know what colour it'll land on.

Related gotcha: if the source photo has a green-screen background, the spill of green light on the subject's edges survives background removal and shows up as a rim once the background is transparent.

Caption text that survives other languages

The caption renders on the right half of the locket, auto-sized to fit. My first implementation broke lines on spaces, which works fine for alex my beloved and falls apart for Japanese, where a sentence can be one unbroken run of characters.

The fitting loop would shrink the font until the single "word" fit — down to about 8px. Unreadable.

The fix is to allow breaks between characters when the script has no spaces:

function segments(text, locale) {
  if (typeof Intl.Segmenter === 'function') {
    const seg = new Intl.Segmenter(locale, { granularity: 'word' });
    return [...seg.segment(text)].map(s => s.segment);
  }
  return text.split(/(\s+)/);
}

Intl.Segmenter knows where Japanese and Chinese text can legally break. It's in every current browser.

Also: load your font before you measure. ctx.measureText() against a font that hasn't loaded yet returns measurements for the fallback, and your carefully fitted caption overflows once the real font swaps in.

await document.fonts.load('32px "Your Font"');

What this adds up to

A 3-second locket at 480×360 comes out around 520KB and takes roughly half a second to generate on a mid-range laptop. All of it runs in the tab — the photo never goes to a server, which for a tool where people upload pictures of their partners and pets is the feature that actually matters.

If you want to see the finished version, it's at QuickImageFix. And if you're building something similar, the order I'd recommend: get the clipping path right, then the easing, then measure before you reach for a worker, and budget an afternoon for GIF transparency.

📰 Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.