3 Weird Bugs I Hit Rendering Typing-Text GIFs in Plain Canvas (Clipped g's, 1-Bit Halos, and Classic Outlook)
A few weeks ago I shipped Typing Text GIF, a small client-side tool that renders typing and typewriter text animations into looping GIFs or APNGs right in the browser. You'd think "draw some text on an HTML5 <canvas> an
A few weeks ago I shipped Typing Text GIF, a small client-side tool that renders typing and typewriter text animations into looping GIFs or APNGs right in the browser.
You'd think "draw some text on an HTML5 <canvas> and encode a GIF" is a solved weekend afternoon project. It almost is โ until the bug reports roll in:
- "Why does the bottom of the letter
gget chopped off when I pick a serif font?" - "Why does my transparent GIF look like a 1998 GeoCities sticker with a crunchy white halo on dark mode?"
- "I put this in my Outlook signature and my boss says I just sent him a blank rectangle with a pipe character."
All three of these have tiny, surprisingly fun fixes in plain TypeScript. None of them require a backend, ffmpeg, or WASM. Here is what was actually going wrong under the hood.
Bug #1: fontSize is a lie (why g and y lose their tails)
My first draft sized the canvas height to something naive like fontSize * 1.25 + padding * 2 and drew text centered at height / 2 with textBaseline = "middle".
That works fine for Inter typing "HELLO WORLD". Then someone types "gypsy jazz" in a bouncy display font, or adds an uppercase ร
on the same line as a p, and the descenders get guillotined right at the bottom edge of the frame.
fontSize: 48 does not mean "every letter fits inside 48 pixels." It's the em-square size. Depending on the font's metrics, ascenders and descenders regularly poke outside it, and "middle" baseline alignment varies subtly across browser text engines.
Luckily, modern 2D canvas gives you the font's real metrics right off ctx.measureText():
function measureLine(ctx: CanvasRenderingContext2D, fontCss: string) {
ctx.font = fontCss;
// Probe string with tall ascenders, deep descenders, and diacritics
const m = ctx.measureText("Mgjpqy|ร
ร");
const ascent = Math.ceil(
m.fontBoundingBoxAscent ?? m.actualBoundingBoxAscent
);
const descent = Math.ceil(
m.fontBoundingBoxDescent ?? m.actualBoundingBoxDescent
);
return {
ascent,
descent,
lineHeight: ascent + descent,
};
}
Two details make this rock-solid:
-
Prefer
fontBoundingBoxAscentoveractualBoundingBoxAscent. If you measure the actual bounding box of each frame as letters type in one by one,"a"has a tiny height, then"ag"suddenly gets taller โ so your text jumps vertically mid-word!fontBoundingBox*gives you the font-wide ceiling and floor regardless of which characters have been typed so far. -
Draw on
"alphabetic"baseline usingtop + ascent. Instead of guessing where"middle"lands, computeconst y = top + rowIndex * lineHeight + ascentand setctx.textBaseline = "alphabetic". Every glyph sits on the exact same baseline from frame 1 to frame 90, andg,j,p,q,ykeep their tails.
Bug #2: The 1998 GeoCities Halo on Transparent GIFs
Canvas renders crisp, anti-aliased text by blending edge pixels to partial alpha (alpha = 0.15, alpha = 0.62, etc.).
The GIF89a spec from 1989 does not know what partial alpha is. In a GIF, a pixel is either 100% opaque or 100% transparent (1-bit transparency, controlled by a single transparentIndex in the 256-color palette).
If you render white text onto a transparent canvas (rgba(0, 0, 0, 0)) and hand those raw bytes to a GIF quantizer:
- Semi-transparent edge pixels have RGB values mixed with whatever was underneath (usually black
0,0,0or white255,255,255), - Every pixel above your alpha threshold snaps to 100% opaque,
- And suddenly your smooth white text has a crusty grey/black outline around every curve.
If the user actually needs true 8-bit smooth alpha over an arbitrary gradient, the honest answer is export APNG (browsers and iMessage love APNG, and upng-js encodes it cleanly).
But email clients, GitHub READMEs, and Notion embeds still expect .gif. The trick old-school Photoshop Web Export used โ and the one that still works like magic โ is matte pre-blending:
/**
* Blend semi-transparent edge pixels against the target background color
* BEFORE snapping alpha to 1-bit.
*/
function matteAlpha(px: Uint8ClampedArray, matteHex: string) {
const mr = parseInt(matteHex.slice(1, 3), 16);
const mg = parseInt(matteHex.slice(3, 5), 16);
const mb = parseInt(matteHex.slice(5, 7), 16);
for (let i = 0; i < px.length; i += 4) {
const a = px[i + 3] / 255;
if (a === 0) continue;
// Composite the glyph's edge pixel over the destination matte color
px[i] = Math.round(px[i] * a + mr * (1 - a));
px[i + 1] = Math.round(px[i + 1] * a + mg * (1 - a));
px[i + 2] = Math.round(px[i + 2] * a + mb * (1 - a));
// Now snap to 1-bit GIF alpha
px[i + 3] = a >= 0.35 ? 255 : 0;
}
}
If your GIF is going onto a dark GitHub README (#0d1117), you set the matte to #0d1117. All the interior pixels of the background stay genuinely transparent (a = 0), while the 1px anti-aliased fringe around each letter fades toward #0d1117 instead of white. Zero halo.
Also: remember dispose: 2 (restore to background) in your GIF encoder when transparent is enabled! Otherwise frame 2 draws right on top of frame 1 instead of clearing the previous cursor position.
Bug #3: Classic Outlook Only Ever Sees Frame 0
If you build a typing animation for "Jane Doe ยท Founder", what is on Frame 0?
Usually: an empty canvas with a lonely blinking cursor |.
Now drop that GIF into an animated email signature. Gmail, Apple Mail, and modern Outlook on Mac/Web play the loop happily. Classic Outlook for Windows (2007โ2019) โ which still uses the Microsoft Word rendering engine under the hood โ refuses to animate GIFs and freezes permanently on Frame 0.
Congratulations: half of your enterprise recipients just got a blank rectangle with a vertical bar at the bottom of your email.
You don't need a separate static fallback image to fix this. Since a looping GIF is a ring buffer of frames, rotating the array doesn't change the loop at all:
if (settings.fullFirstFrame && settings.loop) {
// Find the exact frame where Phrase 0 finishes typing out
const holdStart = frames.findIndex(
(f) => f.phrase === 0 && f.chars === [...phrases[0]].length
);
// Rotate the timeline so Frame 0 IS the completed headline!
return [
...frames.slice(holdStart),
...frames.slice(0, holdStart),
];
}
Think about what happens in both worlds:
-
In Gmail / Apple Mail: The GIF opens on
"Jane Doe ยท Founder", holds for 1.5 seconds with a blinking cursor, backspaces it, types"Book a call: cal.com/jane", backspaces, and types"Jane Doe ยท Founder"right back to the start of the loop. Seamless. -
In Classic Outlook on Windows: Frame 0 is
"Jane Doe ยท Founder". It looks like a crisp, intentional static banner.
One slice call, bug gone forever.
Bonus: Fake Typewriter Ink That Doesn't Flicker
When I added the typewriter text GIF mode, I wanted each letter to land slightly crooked, with uneven ribbon ink (alpha between 0.78 and 1.0).
If you reach for Math.random() inside your draw() function, every letter jitters wildly like a ransom note having a caffeine overdose, because draw() runs once per frame, not once per character!
Instead, hash the character's index with a tiny deterministic sine hash:
const noise = (seed: number) => {
const x = Math.sin(seed * 127.1 + 311.7) * 43758.5453;
return x - Math.floor(x);
};
// Inside the per-glyph loop:
const dy = (noise(glyph.index * 3.1 + glyph.x) - 0.5) * fontSize * 0.06;
const rot = (noise(glyph.index * 7.7 + 1) - 0.5) * 0.05;
const alpha = 0.78 + noise(glyph.index * 5.3 + 2) * 0.22;
Now the 4th letter 'e' gets the exact same -1.2px vertical slip and 84% ink density on frame 4, frame 5, and frame 60 โ and if you re-export with the same text tomorrow, you get the exact same bytes out.
If you want to poke around the rest of the canvas + gifenc implementation, the whole engine is MIT-licensed on GitHub (fancydirty/typing-text-gif), or you can try breaking it live at typingtextgif.com.
What's the weirdest format edge case a 35-year-old spec has ambushed you with lately?
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes โ full credit and traffic to the original publisher.