Paint Forge ships a complete 2D animation system built on two complementary primitives: property keyframing (smooth tweens between snapshots of an object's numeric, color, or path-shape properties) and cel animation (discrete frame-by-frame sequences inside a frame-group layer). Both animation types share the same timeline, playhead, and export pipeline, so you can mix tween-style motion graphics with hand-drawn flipbook sequences in a single project.
Every feature on this page is driven by the Animation panel — a resizable dock at the bottom of the canvas — and persists alongside your project through save, load, and undo/redo.
The Animation panel is a dockable bottom panel under the canvas — timelines are horizontal data, so full viewport width is the right shape. Two ways to open it:
Once open, drag the top edge up or down to resize the dock. The close button (X) in the top-right collapses it — reopen anytime via the command above. Dragging the height below about 120px also auto-closes so you never get stuck with an unreadable strip.
The panel is divided into three horizontal regions:
A compact bar with zoom-out / zoom-in icons, a slider for pixels-per-second (10 – 1000), and a numeric readout. Ctrl/⌘+wheel over the track area zooms around the cursor so the time under your pointer stays stable.
Each animated entity in the scene gets one row. Rows come in three kinds:
Object and layer rows are expandable: click the chevron in the label column to reveal one sub-row per animated property so you can retime individual axes (e.g. just left, just opacity) independently.
A 30-second walkthrough to tween a rectangle across the canvas:
0.00s, click Add Keyframe. A blue diamond appears on the track.pathData field). Irrelevant properties are silently skipped by type.Anything in the tables below can carry independent keyframes. Keyframes are keyed by (entityId, property, time), so re-pressing Add Keyframe at the same time simply updates the existing snapshot rather than duplicating.
| Property | Applies to | Notes |
|---|---|---|
| left, top | Any object | Scene-space position of the object center. |
| opacity | Any object | 0 (transparent) → 1 (opaque). |
| angle | Any object | Rotation in degrees. |
| scaleX, scaleY | Any object | Scale factors. Independent axes. |
| strokeWidth | Any object with a stroke | Pixel width. |
| width, height | Resizable objects | Bounding-box dimensions. |
| fontSize | Text / textbox / textonpath | Numeric px. |
| letterSpacing | Text / textbox / textonpath | Px added between glyphs. |
| lineHeight | Textbox | Multiplier on fontSize (1.3 = default). |
| fillGradientStop0Position … Stop7Position | Objects with gradient fill | Position (0–1) of each stop, capped at 8 stops. |
| Property | Applies to | Notes |
|---|---|---|
| fill | Any object with a solid fill | Hex color; lerps in RGB space. |
| stroke | Any object with a solid stroke | Same as fill. |
| fillGradientStop0Color … Stop7Color | Objects with gradient fill | Color of each stop, capped at 8 stops. |
pathData — for path and textonpath objects. Two keyframes on the same object with different path data get smoothly tweened by resampling both shapes to 128 arc-length points and lerping per-point (bezier curves flatten during the morph, but final keyframe shapes are preserved).
| Property | Effect |
|---|---|
| layerOpacity | Fades the entire layer (composited below its children). |
| layerRotation | Rotates the whole layer around the document center. |
| layerScaleX, layerScaleY | Per-axis layer scale around the document center. Animate each independently for non-uniform stretch. |
| offsetX, offsetY | Translates the layer content independently of object positions. |
Any scalar numeric field inside an adjustment layer's configuration (brightness, contrast, hue, saturation, temperature, tint, exposure, highlights, shadows, clarity, …) can be keyframed via the adj.<field> naming convention. Select the adjustment layer and click Add Keyframe — every numeric field in the current config is snapshotted at once.
Complex structured fields (curves, levels, color balance) are out of scope for v1 — their nested shape needs bespoke interpolation. Scalar fields cover the common case (fade in color grading, pulse brightness, etc.).
Every keyframe carries an easing that controls the curve from the previous keyframe to this one. Right-click a keyframe diamond → Edit Easing… opens the bezier curve editor.
| Name | Curve |
|---|---|
| linear | Constant velocity — no acceleration. |
| ease-in | Starts slow, accelerates. Quadratic. |
| ease-out | Starts fast, decelerates. Quadratic. |
| ease-in-out | Slow at both ends, fast in the middle. |
| bounce | Ease-out with three decreasing bounces. Classic Penner curve. |
| spring | Dampened spring with a slight overshoot, then settles. |
| step-before | No interpolation — jumps to the next value immediately after the prev keyframe. |
| step-after | No interpolation — holds the prev value until the next keyframe time, then jumps. |
The Edit Easing… popover renders a unit-square canvas with two draggable handles — the same four-control-point model CSS uses. Drag P1 (bottom-left handle) and P2 (top-right handle) to shape the curve. Five preset buttons (Linear / Ease / Ease-In / Ease-Out / Ease-In-Out) snap to standard CSS curves. Apply commits the curve onto the keyframe; Cancel discards.
When to use step easings
Click the Graph button in the transport bar to swap the horizontal track rows for a value-curve plot. X axis is time (matching the timeline's duration), Y axis is the keyframed property's numeric value. It's the animator's tool of choice for reading and shaping motion — straight-line diamonds on the track view hide the actual velocity curve; the graph makes it explicit.
Each animated property is a stream. Every visible stream draws its own colored curve; the selected stream renders at full opacity + 1.5 px width, while other streams dim to 0.45 opacity. Hit-testing only engages on the selected stream, so overlapping curves never trip each other. Click a keyframe dot on the selected stream to select it; click the empty plot area to deselect.
Adjacent keyframes in each stream are connected by a 32-sample interpolation curve that respects the arriving keyframe's easing, so the line you see is the actual motion profile — not a straight segment. Value range auto-fits to the union of all visible streams with 10% padding so outliers don't hug the edge.
Toggle the Record button in the transport bar to enter auto-keyframe mode. While record mode is active, every object-level transform commits a keyframe at the current playhead time automatically — no need to press Add Keyframe after each tweak. The scrub → drag → scrub → drag workflow replaces the older add-keyframe → scrub → add-keyframe → scrub loop.
Organic motion without hand-placing dozens of keyframes. Right-click a numeric keyframe → pick a strength:
Wiggle superimposes deterministic noise (sum of two sines at different frequencies) on the eased value within a segment. The noise is amplitude-damped with a quadratic envelope so it's exactly zero at both keyframe boundaries — no discontinuities where segments meet. Each preset seeds with a fresh random integer so re-clicking the same preset produces a different-looking shake.
Remove a wiggle by right-clicking the same keyframe → Remove Wiggle.
Replace the interpolated value with a procedural formula. Right-click a numeric keyframe → Add Expression… opens a textarea with a live preview of the evaluated value at the current playhead.
Examples: time * 50, 50 + sin(time * 2 * PI) * 30, max(0, 100 - time * 100).
Security
eval or new Function. User formulas cannot access window, globalThis, process, or any other runtime global.Animate an object's position along a user-drawn vector path rather than a straight line between keyframes. Workflow:
Motion paths require both the left and top keyframes at the same time to carry the same motionPathId — the attach dialog refuses if only one axis is keyframed.
After attaching, the context menu gains an Orient Along Path toggle. When on, the moving object's angle tracks the path's tangent direction — useful for rockets, fish, cars, or anything that should face where it's going.
The Motion trails checkbox in the transport bar (default on) draws every referenced path as a dashed indigo polyline on the canvas overlay, with an arrowhead at the end. Useful even when the source path object is hidden.
Animating a parent object moves every descendant with it — a character's torso drags head + arms along without extra keyframes.
Cycles are refused automatically (A → B → A), as is self-parenting. The link survives project save / load.
A frame group is a group layer flagged for cel animation. Its child layers become discrete frames — only one is visible at a time, and playback cycles through them at a configured frame rate.
A frame group's timeline row renders cel blocks — live thumbnails of each child frame, sized in proportion to that frame's playback duration. Click a block to jump the playhead to that frame; drag a block horizontally to reorder frames. The thumbnail regenerates via a debounced 5% downscale whenever the frame's content changes.
Two per-frame-group toggles in the Layers panel's expanded options:
Both run BEFORE the history push, so a single Ctrl+Z rolls back the stroke, the sibling copies, AND the frame advance together. Covered tools: brush, vector brush, eraser, spray paint, calligraphy, smudge, blur, sharpen, dodge/burn, color replace, liquify, clone stamp, healing brush, red-eye.
Onion skin ghost-renders adjacent frames around the current one so the animator can see where they're coming from and going to. Paint Forge has two onion-skin systems:
Configured per frame group in the Layers panel. Fields: onionSkinEnabled, onionSkinOpacity, onionSkinPrevFrames / NextFrames (0–3 each), onionSkinPrevTint / NextTint. When enabled, ghosts of neighboring frames render around the active frame on the canvas. Applies to cel animation only.
Covers property tweening. Toggle via the Ghost keyframes checkbox in the transport bar. When on, animated objects render at their previous and next keyframe times in tinted ghost form on the canvas overlay — red-ish for prev, green-ish for next. Pure view-layer; never mutates live object state.
Attach an audio file to the timeline for scrub + playback sync.
When signed in with a cloud project, audio uploads to Supabase Storage and the timeline stores the storage URL — keeps project JSON small. Without a cloud project, the audio is embedded as a base64 data URL in the project (fine for short clips, bloats big ones).
OfflineAudioContext and downsamples to 500 peaks — cached per source string so swapping the same clip back in doesn't re-decode.Once an audio track is attached, a Detect beats button appears in the transport bar. Click it and Paint Forge runs an energy-based onset detector on the decoded audio, dropping a coloured marker (sky-blue, id prefix marker-beat-) on every beat. Compounds beautifully with the keyframe marker-snap — drag a keyframe near a beat and it locks to the rhythm.
Dependency-free, tuned for percussive / drum-heavy music (music-video sync, rhythm games). The pipeline:
maxBpm (default 200 BPM → 300 ms).Not FFT-based on purpose — FFT would sharpen detection on tonal / melodic content, but it adds an extra 200 lines or an external dependency. The simple approach clears the "close to industry quality for drums" bar.
Beat 1, Beat 2, …Good audio for detection
Named colored vertical lines on the ruler that flag important times — beat drops, scene boundaries, cue points.
Colors cycle through a 6-color palette automatically. Markers serialize alongside keyframes and survive save / reload.
Click a keyframe diamond to single-select it. Shift-click (or Ctrl-click) to toggle additional keyframes in the selection.
Dragging a keyframe that's part of a multi-selection moves every selected diamond by the same delta with live group-drag visual preview. The earliest can't go below 0 and the latest can't exceed the timeline duration — if a move would push a keyframe out of range, the whole group's delta is clamped so the worst offender just touches the boundary.
The Snap checkbox in the transport bar (default on) makes keyframe drags stick to two targets:
Neighbor snap takes priority over frame snap takes priority over the raw time. Hold Shift during drag to disable snap temporarily regardless of the checkbox.
The Timeline ops dropdown operates on every keyframe, marker, and state at once:
All three commit a labeled history snapshot so Ctrl+Z rolls the entire transformation back in one step.
The Preset dropdown ships 16 ready-made keyframe recipes. Click one with an object selected at the current playhead, and the preset's keyframes are added in a single step:
| Preset | Effect |
|---|---|
| Fade In | Opacity 0 → current, ease-out. |
| Fade Out | Opacity current → 0, ease-in. |
| Slide Up | Enter from one height below + fade in, ease-out. |
| Slide Down | Enter from one height above + fade in. |
| Slide Left | Enter from one width right + fade in. |
| Slide Right | Enter from one width left + fade in. |
| Pop | Scale 0 → current with spring overshoot + fade. |
| Bounce In | Drop from 2× height above with classic bounce easing + fade. |
| Spin In | Rotate from −360° + scale up + fade in. |
| Shake | Horizontal jitter — 6 oscillations, ends at the current position. |
| Pulse | Gentle scale pulse 1× → 1.1× → 1× — draws the eye without moving the object. |
| Tada | Scale bump + rotation wiggle — a theatrical "look at me" animation. |
| Zoom In | Scale 0.3× → current with ease-out (no fade). |
| Zoom Out | Scale current → 0.3× with ease-in + fade. |
| Pop Out | Scale current → 0 + fade out (inverse of Pop). |
| Spin Out | Rotate to +360° + scale to 0 + fade out (inverse of Spin In). |
Each preset snapshots the object's current transform and opacity as the target state — so moving the object and reapplying still animates to the right position. Duration defaults to 800ms, clamped to the remaining timeline length.
Save snapshots of the entire timeline under a name ("idle", "walk", "jump") and switch between them. Perfect for game / UI motion state-machine workflows.
The audio track is not part of state snapshots — it stays shared across states, matching the common pattern of one audio bed accompanying many poses.
Large projects can have dozens of animated entities. The Active layer only checkbox in the transport bar filters the track list to show just the entities on the currently-active layer — objects whose layerId matches, plus the layer itself and any frame groups that ARE the active layer. Toggles in real time as you switch layers.
All shortcuts fire when the Animation panel has keyboard focus (click anywhere inside it first). Most require no modifier — Shift for case-sensitive variants; clipboard shortcuts need Ctrl / Cmd.
| Shortcut | Action |
|---|---|
| Space | Play / Pause transport. |
| Home / End | Jump playhead to 0 / duration. |
| ← / → | Step playhead one frame backward / forward. |
| K | Add keyframe for the active entity at the current time. |
| Shift + K | Delete every keyframe on the active entity within ±1ms of the playhead. |
| , / . | Jump to the previous / next distinct keyframe time. |
| Shift + , / Shift + . | Jump to the previous / next marker. |
| M then 1–9 | Jump to marker N (500ms chord window). |
| Ctrl + C / Ctrl + V | Copy / paste selected keyframes. |
| Del / Backspace | Delete selected keyframes. |
| Esc | Clear keyframe selection (or cancel motion-path / rigging pick modes). |
| Alt + , / Alt + . | Step through frames in the active frame group. |
Every timeline mutation — add / delete / retime keyframe, change easing, attach / detach motion path, toggle orient-along-path, add / remove wiggle, set expression, apply preset, save / load / overwrite / delete state, reverse / scale / offset timeline, add / rename / delete marker, load / clear audio — commits a labeled history snapshot so Ctrl + Z rolls them back alongside canvas edits.
Non-destructive operations (scrub, play / pause, selection, expansion toggle, display checkboxes) explicitly do NOT commit history — they don't change the source of truth.
On touch devices (viewport width ≤ 767px portrait or ≤ 500px height landscape), the timeline auto-adapts:
Three command-palette imports turn external animation files into an editable frame group you can retouch, rotoscope over, or re-export.
Ctrl+K → Import Animated GIF… decodes every frame of a .gif file into its own drawing layer, groups them, and flips the group into a frame group. The source GIF's per-frame delays become per-layer frameDuration overrides so the first playback / re-export preserves the original timing exactly. After import you can paint directly on any frame, insert new frames, delete ones you don't want, or re-export in any supported format.
Ctrl+K → Import Video… opens a dialog that samples MP4 / WebM / MOV files at a configurable FPS with optional start / end trimming. Each sampled frame becomes a child layer inside a new frame group — the foundation of rotoscope and video-reference workflows. Sampling uses an offscreen <video> element plusdrawImage; no server round-trip, no FFmpeg required.
Ctrl+K → Import Lottie JSON… ingests shapes (rect, ellipse, polygon, star, line, path) and image layers from a Bodymovin / Lottie JSON file, preserving solid and gradient fills. Useful for starting from a designer-delivered motion mockup and repainting / re-animating it inside Paint Forge. Keyframe playback import is future work — only the static frame at time 0 is imported today.
Six formats from the unified Animation Export dialog (File → Export Animation), plus Lottie JSON and CSS @keyframes via their own paths. All formats render through the full timeline — interpolated keyframes, frame-group cels, motion paths, parent-child rigging.
| Format | Best for | Codec / output |
|---|---|---|
| GIF | Universal web previews + social | 256 colours/frame via gifenc. Max 100 frames, max dimension 2048 px. |
| WebM | Long clips, smallest files | VP8/VP9 via MediaRecorder + canvas.captureStream(). Modern browsers only. |
| MP4 | Wide device compatibility | H.264 via browser-native encode; falls back to WebM with a .webm extension if the browser lacks MP4 encode. |
| APNG | Lossless alpha animation | Animated PNG with full alpha. Bigger than WebM/MP4 but pixel-perfect. |
| Sprite Sheet | Game engines / frame atlas | Single tiled PNG of all frames; metadata JSON attached. |
| Image Sequence | Video editor post-production | ZIP of numbered PNG / JPEG files (one per frame). Drop-in for DaVinci, Premiere, After Effects. |
The dialog lets you set output scale, quality (lossy formats), frame delay / FPS, loop count (infinite / once / custom), which layers participate, whether the canvas background is baked in or kept transparent, and — importantly — motion blur parameters.
Every format except Sprite Sheet supports over-sampled motion blur:
The Lottie button in the transport bar emits a Bodymovin-compatible JSON file (schema v5.7.1) that plays in any Lottie runtime (web, iOS, Android, React Native). Supported object types: rect, ellipse, polygon, star, line, path, textonpath, image. Supported animated properties: position (left + top merged into center-anchored p), rotation, scale (percent), opacity (percent), fill color (solid + gradient). Unsupported surfaces (text objects, effects, patterns, frame groups, motion paths, audio, adjustment layers, path-data morphing) emit a toast warning listing what was skipped; everything else exports cleanly.
Ctrl+K → Export as CSS @keyframes… emits a pure CSS stylesheet — one @keyframes block per animated object, plus a .pf-obj-* class selector that applies it. No runtime, no JSON, zero dependencies. Supports transforms (translate, rotate, scale), opacity, and solid-colour background-colour. Per-segment easings become animation-timing-function entries per step. Unsupported properties (text-internal, gradient stops, path morphing, effects) are silently skipped with a toast.
Verify the object has at least two keyframes on the property you want to animate (a single keyframe is a static value). Check the timeline row — if the diamonds are all at the same time, the playhead never sees interpolation. Also confirm the playhead is actually moving; if isPlaying is off, Play is disabled.
Motion paths need matching left and top keyframes at the same time. Add the sibling keyframe first, then retry Attach Motion Path.
GIF per-frame palette quantization adds ~256 colors × 3 bytes of palette overhead per frame. For long animations, use the Scale option in the GIF export dialog (0.5× halves pixel dimensions) or switch to WebM for video-compressed output — usually 5–20× smaller.
The panel corrects drift over 30ms automatically. Persistent drift usually means the audio is encoded at a different sample rate than the browser's playback path. Try re-encoding to 44.1 kHz MP3 or use the Audio Offset field to compensate.
Project save / load preserves animation state
Serializer.metadata.timeline, so the full animation surface stays attached to the project across sessions.