How to generate animated icons with TypeScript

How I replaced my Lordicon nav icons with seven animated sprite icons drawn in TypeScript, rendered with sharp, and played on a canvas.

Seven colorful icon buttons float over a pixel-art sunset, ocean, and distant horizon.

I recently redid the animated icons in my site’s sidebar. The old ones were Lordicon exports, and they looked like every other site using the same pack. A cog is also a strange icon for an About page.

The easy way would be to pick another icon pack. But a pack gives you someone else’s drawings and someone else’s motion, and the Lottie player that animated them was the biggest thing in my bundle. I wanted icons that were mine, animated on hover, and cheap enough to load on every page.

So I built a generator that draws every frame in TypeScript, renders them with sharp, and packs them into sprite sheets that a canvas plays on hover. I built it in a long back-and-forth with Claude Code, and almost all of my feedback was visual: too squishy, a flicker on the loop, a gray smear where smoke should be.

The old Lordicon set and the new icons, all generated by one script

The Pipeline

The workflow looks like this:

  1. Drawing: One TypeScript module per icon writes each frame as an SVG string.
  2. Rendering: sharp renders each frame at 4×, shrinks it to 72px, and packs the frames into a sprite sheet.
  3. Playing: A canvas in the sidebar draws one frame at a time while you hover.

The final hover animations, enlarged. On the site they play at 24px.

Step 0: The rules for 24px icons

Icons that look great enlarged can fall apart at 24px. The sidebar only has 576 pixels to draw each frame, so every idea had to survive that.

What the sidebar actually has to work with.

The feedback rounds boiled down to four rules:

  1. Rigid motion only: Things move, turn, and tilt. They don’t squash, stretch, or morph. At 24px a squash looks like a glitch.
  2. Seamless loops: The animation repeats while you hover, so the last frame has to run straight into the first.
  3. No fades, no slivers: A half-transparent shape and a shape thinner than a pixel both turn into a gray smudge. Things appear and leave by growing and shrinking.
  4. Matching weight: All seven are solid shapes of roughly the same mass, so none of them looks heavier than the rest.
The same chimney puff at real sidebar size

The third rule shaped more of the code than any other.

Step 1: Drawing each icon in code

Every icon module exports the same tiny interface: a frame rate and a function that returns the frames.

export interface IconSprite {
  fps: number;
  frames: () => Promise<Buffer[]>;
}

Inside, each frame is a function of u, which runs from 0 to 1 over the loop. Frame 0 doubles as the resting icon, so the icon at rest and the first frame of the hover can never disagree.

The icons took one of two routes. If a shape could be described with a handful of numbers, I wrote it as geometry. If it couldn’t, I started from a reference image and treated its pixels as data.

yes no Describable in numbers? Write SVG geometry Start from a raster Home, About, Blog, Theme Contact: 7 points read off a reference ink: gray to alpha regions: label connected parts Work: split body, handle, steam Search: clean up a reference
Five icons are pure geometry. Two start from a raster, cleaned up by code on every run.

Home: Shapes as template strings

The house is three paths and a mask, written as a template string:

const frameSvg = (
  u: number,
) => `<svg xmlns="http://www.w3.org/2000/svg" width="${BIG}" height="${BIG}">
  <defs><mask id="door"><rect width="${BIG}" height="${BIG}" fill="#fff"/><path d="${door}" fill="#000"/></mask></defs>
  <g fill="#000">${smoke(u)}</g>
  <g mask="url(#door)" fill="#000" stroke="#000" stroke-width="${ROUND * SC}" stroke-linejoin="round">
    <path d="${chimney}"/><path d="${walls}"/><path d="${roof}"/>
  </g>
</svg>`;

Each shape gets a stroke in its own fill color with stroke-linejoin="round", which rounds every corner without writing a single arc. The door is a mask rather than white paint, so it stays transparent when the theme changes.

The house at 8× with the smoke path overlaid

The smoke is where the fade rule bites. Three puffs start about a quarter-loop apart. Each one grows as it rises, then shrinks away, and anything under 1.4 units is skipped because it would only render as a speck.

const e = 1 - (1 - t) ** 1.6; // rises fast, slows near the top
const r = (2.2 + 2.6 * e) * (t < 0.78 ? 1 : 1 - (t - 0.78) / 0.22);
if (r < 1.4) continue; // no specks

About: Keyframe tracks and a fake sphere

The face glances down, then rises, tilts and winks. That’s five things changing at once: yaw, pitch, roll, a vertical rise, and how closed the right eye is. Each one is a keyframe track with cosine easing between the keys.

const inOut = (t: number) => 0.5 - 0.5 * Math.cos(Math.PI * t);

function track(keys: [number, number][], u: number) {
  if (u <= keys[0][0]) return keys[0][1];
  for (let k = 1; k < keys.length; k++) {
    const [u0, v0] = keys[k - 1];
    const [u1, v1] = keys[k];
    if (u <= u1) return v0 + (v1 - v0) * inOut((u - u0) / (u1 - u0));
  }
  return keys.at(-1)![1];
}

Every track starts and ends at 0, so the loop is seamless and frame 0 is the resting face.

Five tracks across one 1.8-second loop

To sell the head turning, the eyes ride an imaginary sphere slightly larger than the face. A glance moves each eye along the sphere and squeezes it by the cosine of its angle, the same foreshortening trick I used for the interactive avatar. The face itself never changes shape.

Blog: A loop that never resets

The book’s pages flip continuously from the right cover to the left. A naive version flips one page per loop and visibly resets, so I split the space between the covers into three slots instead.

Pages live on angles between the two covers

Each loop moves every page exactly two slots, so the last frame is the first frame with the pages renamed.

const shift = (STEPS * f) / TOTAL; // slots moved so far this loop
for (let k = -STEPS - 1; k < SLOTS + 1; k++) {
  const phi = PHI_MIN + (k + shift) * SLOT;
  if (phi > PHI_MIN + 1e-3 && phi < Math.PI - PHI_MIN - 1e-3) phis.push(phi);
}
phis.sort((a, b) => Math.sin(a) - Math.sin(b)); // flatter pages are further back

The white gaps between pages broke the sliver rule. Near a cover, the gap became a thin wedge that flickered on and off at 24px. Now the gap shrinks to zero within about 25° of either cover, and the page merges into the cover until there’s room for a real gap.

Every fifth frame of one loop

Contact: Seven points and one late cut

For the paper plane, I read seven coordinates off a reference drawing and mapped them into icon space with a scale and an offset. A plane is a polygon, so seven points describe it completely.

The seven points, the rounding stroke, and the fold slit

The fold slit is where the house’s rounding trick backfired: the fat stroke filled the narrow slit back in. The fix was ordering. I draw the body closed and rounded, then cut the slit afterwards with a mask.

The motion is a pull-back, a push forward, one swoop, and a glide back to rest. A single envelope ramps from 0 to 1 and back, and every motion channel is multiplied by it, so 120 frames of busy motion still start and end on the resting plane.

const flying = (u: number) =>
  smoothstep(seg(u, 0.03, 0.18)) * (1 - smoothstep(seg(u, 0.82, 0.97)));

Work: Tracing a raster and faking a turntable

Some shapes are easier to draw than describe. For the mug, I generated a flat icon with an image model and treated the PNG as data. A flood fill labels its connected regions: the biggest is the mug, the ones above it are steam, and the part right of the handle’s hole is the handle.

The source, the regions the script finds, and the spin

With the body and handle separated, a 360° spin is just projection. The handle’s width is scaled by |cos θ|, and it’s drawn behind the body while sin θ is negative. No 3D model, and nothing squashes.

const hw = Math.round(handleW * Math.abs(c)); // c = cos(theta)
const behind = Math.sin(theta) < 0;
if (handleLayer && behind) layers.push(handleLayer);
layers.push(bodyLayer);
if (handleLayer && !behind) layers.push(handleLayer);

The binoculars took the same route: a reference drawing, cleaned up by the same region code.

Theme: A mask, not a morph

The theme toggle has to turn a sun into a moon. That sounds like a morph, which rule 1 forbids, but it’s actually a mask. The disc grows from radius 5 to 10 while a masking circle slides in and bites out the crescent.

Sun to moon with one mask

The rays do fade here, the one exception to rule 3. This sprite doesn’t loop: hovering plays it once in under a second, so there’s no repeating loop to flicker. In dark mode, the same frames play backwards.

Step 2: Rendering the sprite sheets

A sprite sheet is a single PNG holding every frame of an animation in a grid. The browser downloads one image, and the canvas draws one cell at a time.

The home icon's sprite sheet

Every frame is drawn at 288×288, four times the frame size, and shrunk to 72. Drawing big and shrinking gives curves and diagonals soft, even edges. 72 is 24 × 3, so a 3× phone screen gets one sprite pixel per screen pixel.

export const FRAME = 72;
export const SC = 4;
export const BIG = FRAME * SC;

export async function svgFrame(svg: string): Promise<Buffer> {
  const big = await sharp(Buffer.from(svg)).png().toBuffer();
  return sharp(big).resize(FRAME, FRAME).png().toBuffer();
}

The entry script lays the frames out in rows of ten, writes the sheet and the resting still to public/icons/, and writes src/data/icon-sprites.json with each icon’s columns, rows, frame count and frame rate. Before writing, it checks that the frames actually animate, have smooth edges, and have a transparent background.

bun scripts/generate-icon-sprites.ts SVG at 288px resize to 72px sprite sheet resting still icon-sprites.json SpriteIcon canvas icon module sharp renders checks
Each module owns its drawing and motion. The entry script owns the file format.

Step 3: Playing them with Canvas

Playing the sheets works the same way as the canvas in my pixel-art hero. On hover, a requestAnimationFrame loop advances the frame index at the icon’s frame rate and draws that cell:

const col = frame % config.cols;
const row = Math.floor(frame / config.cols);
ctx.drawImage(
  img,
  col * frameWidth,
  row * frameHeight,
  frameWidth,
  frameHeight,
  0,
  0,
  canvas.width,
  canvas.height,
);

The frames are black on transparent, and the canvas gets filter: var(--icon-filter), which is near-black in light mode and white in dark mode. That’s why the generator never picks a color.

1000 / fps ms passed Hover rAF loop Next frame index drawImage at col, row Mouse leaves Draw frame 0 prefers-reduced-motion
Hover starts a requestAnimationFrame loop; leaving, or reduced motion, snaps back to frame 0.

The Result

The generator is about a thousand lines of TypeScript and rebuilds all seven icons in about three seconds. The seven sheets add up to about 450KB: the house is just 21KB, and the paper plane is the heaviest at 156KB for two seconds of 60fps motion.

There’s no animation library at runtime, just a <canvas>, and changing any motion is a matter of editing a number and rerunning the script.

It’s a lot of code for seven icons, but I’ll never have to settle for someone else’s cog again.

Subscribe to my newsletter

I send out a newsletter every week, usually on Thursdays, that's it!

You'll get two emails initially—a confirmation link to verify your subscription, followed by a welcome message. Thanks for joining!

You can read all of my previous issues here

Related Posts.