Blog / Learning notes

What I Learned Building a Canvas Design and Animation Editor

Cover image for What I Learned Building a Canvas Design and Animation Editor

Kinetica is a design and animation editor I built that runs in the browser. You can set text with effects, draw, add shapes and images, animate layers on a timeline and export the result as PNG, JPG, SVG, WebM video or GIF. It's built with React, TypeScript, Vite and Zustand, and there is no backend. Projects are saved in the browser.

The most important decision was to draw everything with my own Canvas 2D renderer instead of using DOM elements for layers.

One renderer for everything

The renderer lives in src/core/render.ts. Its main function, renderScene, takes a canvas context, the project and a few options: the current time, whether animation is on, which layers to hide and whether to draw the background.

It doesn't know whether it's drawing the editor, a thumbnail or a video frame, so it can be shared:

  • The editor stage sets a transform for zoom and pan, then calls renderScene. Selection handles are drawn on top.
  • Text style previews in the side panel use the same drawElement function. So the thumbnail for a style like "neon" is drawn by the code that will actually draw your text.
  • Exports and project thumbnails go through a small helper, renderFrame, which sizes a canvas and calls the same function:
export function renderFrame(project: Project, opts: { time: number; animate: boolean; scale: number; background?: boolean; canvas?: HTMLCanvasElement }) {
  const c = opts.canvas ?? document.createElement('canvas')
  const w = Math.max(1, Math.round(project.artboard.width * opts.scale))
  const h = Math.max(1, Math.round(project.artboard.height * opts.scale))
  if (c.width !== w) c.width = w
  if (c.height !== h) c.height = h
  const ctx = c.getContext('2d')!
  ctx.setTransform(1, 0, 0, 1, 0, 0)
  ctx.clearRect(0, 0, w, h)
  ctx.setTransform(opts.scale, 0, 0, opts.scale, 0, 0)
  renderScene(ctx, project, { time: opts.time, animate: opts.animate, background: opts.background })
  return c
}

Because the stage and the exporter share a renderer, what you see in the editor is what gets exported. Exporting at 2× just means a bigger scale in the transform. The one exception is SVG. SVG export writes vector markup in its own function, so live text stays text, and the README notes that it simplifies eraser strokes and the built-in patterns.

Animation is a function of time

I wanted the renderer to stay simple, so it never keeps animation state. For any layer and any time t, an evaluate function works out where the layer is: its position, rotation, scale, opacity and any preset effect. So scrubbing, looping and exporting all just ask for a frame at a given time.

Keyframes

Keyframes cover X, Y, scale, rotation and opacity. The interpolate function in keyframes.ts finds the two keyframes around t and blends between them. The easing set on a keyframe controls the segment that starts at it. Outside the keyframes, the value holds at the nearest one. upsertKeyframe replaces any key within 1/60 of a second of the new one, so dragging at the same playhead spot updates one keyframe instead of piling up several.

Moving a layer works differently in the two views. In Motion view, moving a layer that already has keyframes sets a key at the playhead (auto-key). In Design view, the same move shifts every keyframe by the same amount, so the whole animation moves with the layer.

Presets on top of keyframes

Each layer also has a visibility window (start and end on the timeline) and optional entrance, exit and emphasis presets. Exit presets don't have their own animation code. Each exit is an entrance definition played in reverse, with an ease-out curve swapped for the matching ease-in. Emphasis presets can loop for the whole time the layer is visible.

Undo that doesn't fill up with slider ticks

The project is changed through Immer, so every edit produces a new immutable snapshot. A small generic History class stores past snapshots. The problem was sliders: dragging one fires many changes, and I didn't want one undo step for each. So push takes an optional key, and changes with the same key close together in time merge into one step. Pointer gestures like moving or resizing use a separate begin/live/end flow. The snapshot is taken when the gesture starts and saved once when it ends.

Exporting video and GIFs in the browser

  • WebM uses canvas.captureStream() with MediaRecorder. It picks the first supported format (VP9, VP8, then MP4 for Safari) and renders frames in real time with requestAnimationFrame. The tab has to stay visible while it records. Files from MediaRecorder have no duration header, so I patch it with fix-webm-duration, which lets players seek.
  • GIF renders frame by frame at exact times, reduces each frame to a 256-colour palette with gifenc, and yields to the browser every few frames so the progress bar can update.

Projects auto-save to IndexedDB through idb-keyval. If IndexedDB fails, saving falls back to localStorage, and you can also export a project as JSON.

What I learned

Keeping the renderer free of state, with time as just another input, made the rest of the app easier to build. Previews, thumbnails and exports came almost free because they all call the same function. The trade-off is that I had to build things the DOM gives you for free, like hit-testing, text layout and snapping.


You can try Kinetica at motion-studio-delta-tan.vercel.app, and the source is on GitHub at mfaysal-dev/motion-studio.

// note

How to read this note.

This is a learning note from studying the web. It is one small topic, written so I can remember it. It is not a course and not a claim that I have finished the subject.

If a sentence is wrong, say so from the contact page and name this title. Drafts never appear here. Related notes, when they exist, are other published posts, and the same sample rule applies to each of them.

Related notes

Sample note: what I am using PHP for
Learning notes Sample

Sample note: what I am using PHP for

A sample article for the blog layout. It is a practice note, not a finished tutorial or a claim of expertise.

September 20, 2026 · 2 min read