Status: Design Author: Claude Opus 4.6 Created: 2026-03-12 Tracking: v0.4 milestone (ProjectRoadmap)
Summary
Implement SVG animation support aligned with the SVG Animations Level 2 specification (separated from SVG2 core into its own module). This covers the four animation elements (<animate>, <set>, <animateTransform>, <animateMotion>), the SMIL-derived timing model, value interpolation with multiple calcModes, and the animation sandwich composition model.
Donner is primarily a static/offline SVG renderer, so the implementation focuses on time-based evaluation: given a document time t, compute the presentation value of every animated attribute. Interactive event-based triggers (click, mouseover) are deferred to a future interactive milestone.
Goals
- Parse and evaluate all four SVG animation elements.
- Implement the SMIL timing model: begin/dur/end/repeatCount/repeatDur/fill/restart.
- Support all calcModes: discrete, linear, paced, spline.
- Support keyTimes, keySplines, values, from/to/by value specification.
- Implement the animation sandwich model for multi-animation composition.
- Support additive and accumulate semantics.
- Support syncbase timing (id.begin, id.end references).
- Provide a public API: document.setTime(t) to advance the document clock.
- Integrate with existing ECS invalidation so animated properties trigger re-render.
- Pass targeted animation tests from the SVG test suite.
Non-Goals
- Interactive event-based triggers (click, mouseover, accessKey, wallclock). These require an event dispatch system not yet in Donner.
- CSS Animations / CSS Transitions / Web Animations API integration. These are separate specs with different semantics; they can layer on top later.
- <animateColor> (removed in SVG2, redundant with <animate> targeting color properties).
- Real-time playback loop or frame scheduling. The caller controls time advancement.
- GPU-accelerated animation (compositing-layer promotion for transform/opacity).
- Animation Elements 1.0 (future W3C spec with <par>/<seq> containers — still early-stage).
Background
Specification Landscape
SVG2 moved animation out of the core spec into SVG Animations Level 2 (Editor's Draft). The animation model is SMIL-derived but self-contained — SVG Animations fully defines its features without normative reference back to SMIL Animation. All major browsers support SVG SMIL animation (97%+ global coverage), and Chrome reversed its 2015 deprecation decision.
Four animation elements are defined:
| Element | Purpose | Default calcMode |
| <animate> | Animate scalar attributes/properties with interpolation | linear |
| <set> | Set attribute to discrete value for a duration | discrete (only) |
| <animateTransform> | Animate transform attribute by type | linear |
| <animateMotion> | Move element along a path | paced |
<mpath> is a child of <animateMotion> referencing a <path> element as the motion source.
Timing Model
The timing model determines when an animation is active and where within its duration the current time falls.
Document time (t)
│
▼
┌─────────────────────────────────────────────────────────┐
│ begin active duration end │
│ ├──────────────────────────────────────────────────┤ │
│ │ simple dur │ simple dur │ simple dur (rep) │ │
│ ├──────────────┼──────────────┼───────────────────┤ │
│ │ │ │ ▲ freeze │ │
│ └──────────────┴──────────────┴─────────┘─────────┘ │
└─────────────────────────────────────────────────────────┘
Clock values: HH:MM:SS.frac, MM:SS.frac, or <number><metric> (h/min/s/ms). Default metric is seconds.
Active duration computation:
- Simple duration from dur (or indefinite)
- Repeat duration = min(dur × repeatCount, repeatDur)
- Constrain with end attribute
- Clamp to [min, max] bounds
begin value types (semicolon-separated list):
- Offset: 2s, -1s (relative to document start)
- Syncbase: other.begin+0.5s, other.end (synchronized to another animation)
- Repeat: other.repeat(3) (triggered on Nth repeat of another animation)
- indefinite (begin only via beginElement() API)
- Event/accessKey/wallclock (deferred — requires interactive event system)
fill: remove (revert to base value when done) or freeze (persist final value).
restart: always | whenNotActive | never.
Value Interpolation
Values are specified via from/to/by pairs or a values list (which takes precedence):
| Specification | Meaning |
| from/to | Interpolate from start to end |
| from/by | Interpolate from start to start+offset |
| to only | "To-animation": interpolate from underlying value to target |
| by only | "By-animation": add offset to underlying value |
| values | Keyframe list (semicolon-separated) |
calcMode controls interpolation between keyframes:
- discrete: Jump between values at keyTime boundaries. Auto-selected for non-interpolable types (strings, enums).
- linear: Linear interpolation. Default for <animate>, <animateTransform>.
- paced: Constant velocity. Requires a distance function. Default for <animateMotion>. keyTimes is ignored.
- spline: Cubic Bezier easing per interval, controlled by keySplines (x1 y1 x2 y2 control points in [0,1]).
keyTimes: Maps values entries to positions in [0,1]. First must be 0, last must be 1 (for linear/spline). Count must match values count.
keySplines: One set of Bezier control points per interval (count = keyTimes count - 1). Only used when calcMode="spline".
Distance Functions (for paced mode)
| Type | Distance |
| Number/length | \|a - b\| |
| Color (RGB) | sqrt(dR² + dG² + dB²) |
| translate | sqrt(dx² + dy²) |
| scale | sqrt(dsx² + dsy²) |
| rotate/skew | \|dAngle\| |
Types without a defined distance function fall back to discrete.
Animation Sandwich Model
When multiple animations target the same attribute, they compose via a layered sandwich:
┌─────────────────────────┐
│ Animation C (newest) │ ← highest priority
├─────────────────────────┤
│ Animation B │
├─────────────────────────┤
│ Animation A (oldest) │
├─────────────────────────┤
│ Base value (DOM/CSS) │ ← lowest priority
└─────────────────────────┘
- additive="replace": Animation overrides all lower layers.
- additive="sum": Animation adds to the result of all layers below.
- Frozen animations remain in the sandwich at their final value.
- Restarting an animation moves it to the top of the priority stack.
- The presentation value = result at the top after all layers compose.
<animateTransform> Type Dispatch
The type attribute selects the transform function:
| type | values format | result |
| translate | tx [ty] | translate(tx, ty) |
| scale | sx [sy] | scale(sx, sy) |
| rotate | angle [cx cy] | rotate(angle, cx, cy) |
| skewX | angle | skewX(angle) |
| skewY | angle | skewY(angle) |
By default, <animateTransform> replaces the entire transform attribute. With additive="sum", it post-multiplies with the underlying transform.
<animateMotion> Supplemental Transform
<animateMotion> creates a supplemental transform matrix applied on top of the element's existing transform. The motion path is specified via:
- path attribute (SVG path data)
- <mpath> child element (reference to a <path>)
- from/to/by as point pairs (implicit linear path)
- values as point list
The rotate attribute controls orientation along the path:
- auto: tangent direction
- auto-reverse: tangent + 180°
- <number>: fixed angle in degrees
keyPoints (semicolon-separated [0,1] values) maps keyTimes to progress along the path.
Animatable Attributes
Key categories:
Geometry: cx, cy, r, rx, ry, x, y, x1, y1, x2, y2, width, height, d, points, pathLength
Transform: transform, gradientTransform, patternTransform
Presentation: opacity, fill, fill-opacity, stroke, stroke-width, stroke-opacity, stroke-dasharray, stroke-dashoffset, color, visibility, display, clip-path, filter, font-size
Paint server: offset, fx, fy, spreadMethod, stop-color, stop-opacity
Filter: flood-color, flood-opacity, lighting-color, stdDeviation
Path d animation: Start and end paths must have identical command structure (same number and types of commands). Interpolation is per-coordinate. Mismatched structures fall back to discrete.
Architecture
ECS Integration
Animation integrates into the existing ECS via new components and a new AnimationSystem:
┌──────────────────────────────────────────────────────────────────┐
│ Registry │
│ │
│ Target entity Animation entity │
│ ┌─────────────────┐ ┌──────────────────────────────┐ │
│ │ ShapeComponent │◄─────────│ AnimationTimingComponent │ │
│ │ StyleComponent │ targets │ begin, dur, end, repeat │ │
│ │ LayoutComponent │ │ fill, restart, min, max │ │
│ │ │ ├──────────────────────────────┤ │
│ │ AnimatedValues │ │ AnimationValueComponent │ │
│ │ sandwichStack │ │ from, to, by, values │ │
│ │ overrideMap │ │ calcMode, keyTimes │ │
│ └─────────────────┘ │ keySplines, additive │ │
│ │ accumulate, attributeName │ │
│ ├──────────────────────────────┤ │
│ │ AnimationStateComponent │ │
│ │ activeInterval, phase │ │
│ │ currentIteration │ │
│ │ simpleDuration │ │
│ │ activeDuration │ │
│ └──────────────────────────────┘ │
│ │
│ AnimateTransform-specific: │
│ ┌────────────────────────────┐ │
│ │ AnimateTransformComponent │ │
│ │ type (translate/scale/…) │ │
│ └────────────────────────────┘ │
│ │
│ AnimateMotion-specific: │
│ ┌────────────────────────────┐ │
│ │ AnimateMotionComponent │ │
│ │ path, rotate, keyPoints │ │
│ │ mpathRef │ │
│ └────────────────────────────┘ │
└──────────────────────────────────────────────────────────────────┘
Animation elements are parsed as child entities of their target element (following SVG DOM structure). Each animation element becomes an entity with timing, value, and state components.
AnimationSystem
AnimationSystem is a new ECS system that runs before LayoutSystem and StyleSystem during rendering. Given a document time t:
- Resolve timing: For each animation entity, compute whether it is in the before, active, or after phase. Resolve syncbase dependencies (topological sort of begin/end references).
- Compute values: For active animations, interpolate the current value based on calcMode, keyTimes, keySplines, and the current position within the active duration.
- Compose sandwich: For each animated attribute on each target, layer all contributing animations in activation order, applying additive/accumulate semantics.
- Apply overrides: Write the final presentation values into an AnimatedValuesComponent on the target entity. The style/layout systems read from this override map instead of the base attribute when present.
- Invalidate: Mark affected entities dirty so the renderer reprocesses them.
Animated Property Storage
Each target entity that has active animations gets an AnimatedValuesComponent containing:
struct AnimatedValuesComponent {
std::unordered_map<std::string, AnimatedValue> overrides;
std::unordered_map<std::string, SandwichStack> sandwiches;
std::optional<Transformd> motionTransform;
};
AnimatedValue is a variant over the supported value types:
using AnimatedValue = std::variant<
double,
Lengthd,
Color,
Transformd,
PathSpline,
std::string,
std::vector<double>
>;
Value Type Interpolation
Each AnimatedValue type needs an interpolation function:
AnimatedValue interpolate(const AnimatedValue& a, const AnimatedValue& b, double t);
AnimatedValue add(const AnimatedValue& a, const AnimatedValue& b);
double distance(const AnimatedValue& a, const AnimatedValue& b);
- Numbers/lengths: Linear a + (b - a) * t.
- Colors: Component-wise linear in sRGB. Color(lerp(r), lerp(g), lerp(b), lerp(a)).
- Transforms: Decompose to translate/scale/rotate/skew, interpolate components, recompose. For <animateTransform>, interpolation is per the declared type (no decomposition needed).
- Path d: Validate structural compatibility (same command sequence), interpolate per-coordinate. Fall back to discrete on mismatch.
- Strings/enums: Discrete only (no interpolation).
- Number lists: Element-wise linear interpolation. Lists must have same length.
Timing Resolution with Syncbase Dependencies
Syncbase values create dependencies between animations:
<animate id="a" begin="0s" dur="2s" .../>
<animate id="b" begin="a.end" dur="1s" .../>
<animate id="c" begin="b.begin+0.5s" dur="1s" .../>
These form a DAG (directed acyclic graph). Resolution:
- Build dependency graph from begin/end syncbase references.
- Topological sort (detect cycles — cyclic dependencies are an error per spec).
- Resolve in dependency order: compute each animation's active interval, then propagate to dependents.
For the MVP, a simple iterative fixed-point approach works: resolve all non-dependent animations first, then iterate until all syncbase references stabilize.
Integration Points
Parser (SVGParser): Add element handlers for <animate>, <set>, <animateTransform>, <animateMotion>, <mpath>. Parse timing attributes, value attributes, and element-specific attributes. Create animation entities as children of target elements.
ElementType: Add Animate, Set, AnimateTransform, AnimateMotion, MPath.
Style resolution: ComputedStyleComponent checks AnimatedValuesComponent::overrides before base attribute values. Animated values take precedence over attribute values but NOT over inline style with !important.
Transform computation: LayoutSystem applies AnimatedValuesComponent::motionTransform as a post-multiply on the element's computed transform.
RendererDriver: Add setDocumentTime(double seconds) to the public API. This triggers AnimationSystem::advance(t) before the render traversal.
Implementation Plan
Phase 1: Timing Model and <set> Element ✅
Core timing infrastructure with the simplest animation element.
- Add ElementType::Animate, Set, AnimateTransform, AnimateMotion
- Parse <set> element: attributeName, to, timing attributes
- Implement AnimationTimingComponent with clock value parsing
- Implement timing resolution: compute active interval from begin/dur/end
- Implement AnimationStateComponent phase tracking (before/active/after)
- Implement AnimatedValuesComponent with single-animation override
- Integrate with style resolution: check overrides before base values
- Add Document::setTime(double) public API
- Tests: <set> targeting fill, visibility, opacity at various times
Phase 2: <animate> with Linear Interpolation ✅
- Parse <animate> element: from/to/by/values, calcMode, keyTimes
- Implement AnimateValueComponent with value list parsing
- Implement interpolation for numbers
- Implement calcMode="linear" with keyTimes
- Implement calcMode="discrete"
- Implement repeatCount/repeatDur with correct active duration computation
- Implement fill="freeze" vs fill="remove"
- Tests: 16 tests covering parsing, linear, discrete, from/by, freeze, repeat, keyTimes, href
Phase 3: Spline and Paced Interpolation ✅
- Implement calcMode="spline" with keySplines cubic Bezier evaluation (Newton-Raphson)
- Implement calcMode="paced" with cumulative distance functions
- Implement min/max active duration constraints
- Tests: 6 tests — spline ease-in-out, multi-interval spline, paced even/uneven
Phase 4: Animation Sandwich Model ✅
- Implement document-order composition (entities sorted by ID)
- Implement additive="sum" composition (numeric addition)
- Implement accumulate="sum" across repeat iterations
- Implement activation-order priority (last-wins for replace mode)
- Implement frozen animation persistence in sandwich
- Tests: 5 tests — last-wins, additive sum, accumulate sum, set interaction, frozen
Phase 5: <animateTransform> ✅
- Parse <animateTransform>: type attribute (translate/scale/rotate/skewX/skewY)
- Implement per-type value parsing (1-3 numbers depending on type)
- Implement per-type interpolation
- Implement additive transform composition (string concatenation)
- Tests: 10 tests — parsing, interpolation for rotate/translate/scale/skewX, freeze, additive
Phase 6: <animateMotion> ✅
- Parse <animateMotion>: path, from/to/by/values, rotate
- Implement path evaluation with arc-length parameterization
- Implement rotate="auto" / auto-reverse / fixed angle
- Build implicit linear paths from from/to and values point lists
- Tests: 8 tests — parsing, straight line, L-shaped path, rotate, freeze, values
Phase 7: Syncbase Timing and Path d Animation ✅
- Implement syncbase begin/end resolution (id.begin, id.end)
- Iterative resolution for chains (up to 8 deep)
- Implement path d attribute animation (structural compatibility check, per-coordinate interpolation)
- Tests: 8 tests — syncbase basic/offset/begin-ref/chain/set-trigger, path-d compatible/incompatible/values
Phase 8: Conformance and Polish ✅
- Handle edge cases: zero duration, indefinite duration, negative offsets
- Handle begin with multiple values (semicolon-separated, earliest qualifying time)
- Handle href targeting with # prefix stripping
- Renderer integration: animated transforms applied via TransformComponent
- Renderer integration: animated path d applied via PathComponent
- Tests: 7 edge case tests — negative begin, frozen pre-started, multiple begin, zero dur, min/max
- restart attribute enforcement (never suppresses after completion, whenNotActive blocks restart while active)
- <mpath> child element support (parsing, href resolution, path precedence)
- keyPoints for non-uniform <animateMotion> path progress (evenly-spaced keyTimes interpolation)
- Number-list interpolation (dasharray, points, viewBox — element-wise linear with Euclidean paced distance)
- Geometry attribute animation rendering (cx, cy, r, rx, ry, x, y, width, height, x1, y1, x2, y2)
Phase 9: Fuzz Testing
Add fuzzers for new parsers introduced by animation support. Follow existing pattern (e.g., PathParser_fuzzer.cc, ListParser_fuzzer.cc).
- ClockValueParser_fuzzer — fuzz ClockValueParser::Parse() with arbitrary strings
- AnimateValue_fuzzer — fuzz value list parsing (from/to/by/values attributes)
- AnimateTransformValue_fuzzer — fuzz per-type transform value parsing (translate/scale/rotate/skewX/skewY number lists)
- AnimateMotionPath_fuzzer — fuzz motion path building from path, from/to/by, values point-pair inputs
- SyncbaseRef_fuzzer — fuzz syncbase reference parsing (id.begin+offset, id.end-offset strings)
Verification
Each phase:
- Unit tests for timing computation, interpolation, sandwich composition
- Integration tests with golden image comparison at specific time points
- bazel test //donner/svg/renderer/tests:renderer_tests (static rendering unaffected)
- Targeted SVG test suite animation cases
Performance: Composited Rendering
For performant animation of complex SVGs, a composited rendering system caches static portions of the render tree as pixmaps and only re-renders layers containing animated elements. See composited_rendering.md for the full design.
Key idea: the flat render tree is sliced into contiguous layers. Animated elements (and their subtrees) get dynamic layers that re-render each frame; everything else gets static layers that are cached. The final image is assembled by compositing content-sized layer quads in paint order.
This is a general-purpose system that also supports interactive editing (selected elements on their own layers for efficient drag rendering).
Open Questions
- Event triggers: Should we stub the event-based begin/end parsing now (silently ignore) or reject with a warning? Recommendation: parse and store, but don't resolve — log a warning when an animation depends on unsupported event triggers.
- CSS property animation: <animate> can target CSS properties via attributeName. Should we support the full CSS property set or just SVG presentation attributes? Recommendation: start with SVG presentation attributes that map to CSS properties (fill, stroke, opacity, etc.) since those are already in the style system.
- Incremental invalidation granularity: Should animated properties dirty the entire subtree or just the affected entity? Recommendation: per-entity invalidation for geometry/paint changes, subtree invalidation only for inherited properties (color, font).
- Frame interpolation API: Should we provide a helper that returns "time of next
interesting event" (begin/end/keyframe boundary) for efficient frame scheduling by the caller? This would help callers avoid rendering frames where nothing changed.
References
Addendum: Slice 1 port (2026-07-05)
This document was originally authored against an earlier (tiny-skia) branch. Slice 1 ports a deliberately reduced subset of the animation system onto Donner's current architecture. This addendum records exactly what slice 1 includes, what it defers, the new attach point, and the resolved computed-style-vs-attribute question.
Included in slice 1
- Public API: SVGDocument::setTime(double) / SVGDocument::currentTime(). setTime() stores the time on SVGDocumentContext::documentTime and calls invalidateRenderTree().
- Timing model: ClockValue, AnimationTimingComponent, and AnimationSystem. Supported timing attributes: begin (offset values only, semicolon list picks the earliest non-negative qualifying offset), dur, end (offset only), repeatCount, repeatDur, fill (freeze | remove), restart (always | whenNotActive | never), min, max.
- Elements: <animate>, <animateTransform> (type in translate | scale | rotate | skewX | skewY), and <set>. All are experimental (IsExperimental = true) and require SVGParser::Options::enableExperimental.
- Value specification: from / to / by / values, keyTimes, calcMode discrete | linear.
- Value interpolation retained from the source engine because it falls out of the generic value-string interpolator without adding element/attribute surface: numeric (opacity, geometry lengths, transform components), path d (structural-compatibility check, per-coordinate interpolation, discrete fallback on mismatch), and number-list (dasharray/points, element-wise linear).
- Composition: document-order sandwich with replace semantics (last animation in document order wins for a given attribute). Frozen animations persist at their final value.
- Deterministic render at any t.
Deferred (out of slice 1)
- <animateMotion> and <mpath> (element, component, parser, and system pass all omitted).
- calcMode paced and spline (and therefore keySplines).
- Syncbase timing (id.begin / id.end references) and any begin/end value that is not a plain offset. begin is offset-only.
- additive and accumulate.
- Compositor layer caching for animated elements. HintSource::Animation is intentionally left unused; animation re-evaluates the full render tree each setTime().
- CSS Animations / Transitions / Web Animations, and editor UI.
Permanently out of scope (non-scripting target)
Donner is an offline, non-scripting renderer with no event-dispatch system. Event, accessKey, and wallclock begin/end triggers (click, mouseover, repeat(n), beginElement(), etc.) have no meaning without a scripting/interaction host and are permanently out of scope for the time-sampled evaluation model, independent of slice boundaries.
Attach point
AnimationSystem().advance(registry, documentTime, nullptr) is invoked from RenderingContext::createComputedComponents() immediately after StyleSystem().computeAllStyles(...) and before ResourceManagerContext::loadResources(...). advance() writes string-valued overrides into an AnimatedValuesComponent on each target entity. A following loop applies those overrides to the already-computed components.
Resolved question: overrides patch computed style/geometry post-style
The scoping study flagged a choice between (a) injecting animated values as attribute values that re-run through the normal attribute/style pipeline, versus (b) patching the computed style and geometry after computeAllStyles() (the tiny-skia model). Slice 1 adopts (b):
- AnimationSystem produces, per target, a map of attributeName -> valueString.
- The applier in createComputedComponents() then, per override:
- transform: parses via TransformParser and sets TransformComponent at Specificity::Override().
- d: sets PathComponent::d at override specificity, clears the spline override, and invalidates ComputedPathComponent.
- geometry lengths (cx cy r rx ry x y width height x1 y1 x2 y2): parses as a length and sets the corresponding shape component property at override specificity, then invalidates the computed shape components.
- everything else: ComputedStyleComponent::properties->parsePresentationAttribute(name, value).
Rationale: (b) keeps animation a thin, deterministic post-pass that is trivially re-runnable at any t with no re-parse of the whole document, matches the already-validated source implementation (reducing port risk), and slots cleanly between style and layout. Its cost is that the applier must enumerate the geometry/transform/path special cases, which is acceptable for the slice-1 attribute set and is where future animatable attributes will be registered.
Two port-specific corrections were required relative to the tiny-skia implementation, because the current architecture does not rebuild base components on invalidateRenderTree():
- Base-component backup/restore. ComputedStyleComponent is regenerated each render, so CSS overrides revert for free. Base geometry (CircleComponent etc.), PathComponent::d, and TransformComponent are not regenerated; mutating them would leak across time samples once an animation ends with fill="remove". The applier lazily captures an AnimationBaseBackup per mutated entity and restores it at the start of every apply pass. The RendererAnimation.GeometryRemoveReverts golden test locks this behavior in.
- Computed-transform cache invalidation. ComputedLocalTransformComponent and the cascaded ComputedAbsoluteTransformComponent are cached across renders; applying or restoring a transform override calls LayoutSystem::invalidate() so the animated transform actually reaches the render.