Every time I needed a 15-second animated clip, I had two options: pay for a motion graphics suite I would open twice a month, or fight a template site that exports a watermark.
So I built my own. It's called Riptide, and it runs in a browser tab.
It's not a toy. The promo Short for it was made in it. Here is what it does, how it is built, and the bug that taught me the most.
What it does
Layers and keyframes. Rectangles, ellipses, polygons, stars, arcs, waves, text, images and video. Any property can be keyframed: position, size, rotation, scale, opacity, colour, blur, stroke. There are 23 easing curves, plus ready-made presets like Pop in, Rise, Typewriter and Draw on that you drag onto a layer.
A real timeline. Zoom, per-property keyframe rows, trim bars, split at the playhead, markers, and snapping to the playhead, markers, keyframes and clip edges.
Audio that behaves. Multiple tracks that play together. Drag clips, trim them, split them, and drag the little dots on a clip's corners to fade in and out. Every track gets mixed into the export, fades and volumes included.
Design tools you expect. Multi-select with Shift-click or a selection box, align and distribute, centre on canvas, and an aspect-ratio lock that images get by default so you never squash a photo by accident.
Export. MP4, WebM, GIF, a PNG sequence or a still, up to 4K.
How it's built
The whole editor is plain JavaScript drawing to a single <canvas> with the 2D context. No framework, no WebGL.
Every frame is rendered from scratch by one function:
function renderScene(c, t, scale = 1) {
c.setTransform(scale, 0, 0, scale, 0, 0);
c.fillStyle = S.bg; c.fillRect(0, 0, S.w, S.h);
for (const L of S.layers) if (visibleAt(L, t)) {
c.save(); c.globalCompositeOperation = L.blend || 'source-over';
drawLayer(c, L, t); c.restore();
}
}
That one decision paid off over and over. The editor preview, the fullscreen viewer, the exporter and the frames an AI agent looks at all call the same function with a different context and scale. If it looks right in the editor, it exports right.
The backend is a small Flask server whose main job is handing frames to ffmpeg. The browser renders each frame and uploads it, then the server hands the frames to ffmpeg with the audio tracks mixed in, fades included. A PNG sequence export does not even need ffmpeg.
Keyboard shortcuts live in one table:
['Align', 'Centre on canvas', 'alt+c', () => centerOnCanvas('xy')],
['Edit', 'Select all layers', 'mod+a', () => selectAll()],
That table drives the shortcuts, the cheat sheet and the command palette, so the three can never disagree.
The part people ask about: an AI that animates live
Riptide has a built-in MCP server. Any agent that speaks MCP can connect to the open project and work in it: add layers, set keyframes, apply presets, align things, add audio, and render a frame to look at its own work.
The important design choice: agent edits go through the exact same path as yours. They show up on the canvas instantly, they can be undone with Cmd+Z, and the file is marked as edited. The agent isn't generating a file you import later. It is a second pair of hands in the same document.
Asking an agent to "make the title pop in on the beat and centre everything" and watching it happen on screen is the closest thing to magic I've built.
The bug that broke the whole window
My favorite bug from this project was one word long.
Dragging the divider between the Layers and Media panels made the entire window flicker between the normal layout and a scrambled one. I fixed the obvious suspect first: a feedback loop where the panel scrolled, the measurement shifted, and the next size came out wrong. Still broken.
The real cause: while you dragged, the code added a class to <body> to show the resize cursor. The class for vertical resizing was row.
The app already had a .row class. It styles the little input fields in the side panel:
.row { display: flex; height: 26px; }
So for the length of every drag, the whole page became a 26-pixel-tall flex row.
The fix was to stop putting generic class names on <body> and use a namespaced attribute instead:
document.documentElement.dataset.resizing = axis; // 'x' or 'y'
html[data-resizing] { user-select: none; }
html[data-resizing=y] body::after { cursor: row-resize; }
The lesson I took away: state on <html> or <body> is global, so name it like a global. And when a test measures sizes but never looks at the screen mid-drag, it can pass while the user watches the page collapse.
What I would tell you if you're building something similar
- Render everything from one function. Preview, export and thumbnails should never have their own drawing code.
- Make the agent use the user's edit path. Undo, dirty state and live updates come for free, and you never have two sources of truth.
- Measure from the start of a drag, not the live layout. Anything that re-reads the layout mid-drag can feed back into itself.
- Look at the screen. Numbers in a test log are not the same as what a person sees.
What would you want to see in a browser-based motion editor? And if you've hit a one-word CSS bug that took down a whole page, I want to hear it in the comments.
The repo is Natuworkguy/Riptide on GitHub.
Top comments (0)