The JSON format
The JSON animation document, in one page: the principles behind the format, the full
reference, the player effects, the editor’s meta, and the core library that validates and
transforms documents. (The other shape an animation takes — a finished .svg file — has its
own documentation: Pre-rendered SVG.)
On this page: Format principles · JSON format reference · Player effects · Editor meta and applied effects · Core library
Format principles
Section titled “Format principles”Read this if you write animation files by hand, need to read one to diagnose a problem, or are simply curious why the format looks the way it does.
Plain SVG, with layers on top
Section titled “Plain SVG, with layers on top”Start from what already exists: SVG, the standard format for vector graphics on the web — every browser draws it. What SVG does not give us is a good way to describe animation: how those shapes move, change color, morph over time. The Pixodesk format does not replace SVG to get there. It keeps SVG as its base and adds what is missing on top, one addition at a time — first typed values, then animation, then effects, then the editor’s own data. Each addition is called a layer: plain SVG is layer zero (L0), and every layer above it adds exactly one idea and uses only the layers beneath it.
Two rules hold the stack together.
Rule 1 — the layers don’t mix. Each layer keeps its data in its own place: animation
always under animate, effects under effects, editor data under meta. So a program
reading the file can take the parts it understands and simply skip the rest.
Rule 2 — higher layers get translated down into simpler ones, never the other way. In the end, a browser can only draw plain SVG. So everything a higher layer describes is, at some point, converted into the simpler layers below it. This happens at three moments:
- when the editor saves a file, it converts its editor-only constructs (L4 — for example a star shape preset) into the plain layers below (a path, and its animation);
- when the player loads a JSON file, it converts the effects (L3) into plain elements, attributes and animation (L0–L2);
- when the editor exports a pre-rendered SVG, it converts everything into plain SVG plus CSS (L0).
Whichever of these three conversions runs, its output is always written in the simple layers only.
| Layer | Where its data lives | What it adds | Who reads it |
|---|---|---|---|
| L0 — plain SVG | the element object itself: {type, ...attributes, children} | the drawing — elements and their SVG attributes, exactly as in any SVG; values are plain strings | the browser |
| L1 — typed values | the same SVG attributes as L0 — the layer changes their values, not their place | values become typed instead of strings: numbers (opacity: 0.5), arrays (translate: [96.8, 46.8]), objects (transform: { translate, rotate, scale }). Units are never written — each property has one fixed, implied unit. A value written this way is exactly what a keyframe of L2 holds | the player |
| L2 — animated attributes | node.animate, one entry per animated attribute name | keyframes for any attribute; the element itself and its place in the tree are untouched — delete every animate key and a valid static SVG remains | the player |
| L3 — player effects | node.effects | effects — short descriptions of masks, gradients, copies and other effects (see more), which the player expands into plain elements and attributes when the file loads | the player |
| L4 — editor meta | node.meta | everything only the editor needs — labels, shape presets, the sources of applied effects; the player ignores this key entirely — Editor meta and applied effects | the editor |
| L5 — pre-rendered SVG | unlike the layers above, this one is not a part of the JSON document — it is a separate .svg file the editor produces on export | the same document, converted into an ordinary SVG file: the animation travels as CSS or a script inside it, and the editor data as data-px-meta attributes — Meta in pre-rendered SVG | depends on the flavor: a CSS-animation file is played by the browser alone; a JS-animation file is played by the player embedded in it |
JSON format reference
Section titled “JSON format reference”This page is the reference for the JSON format. It lists every key of the document, with its type and its meaning. A document is simply SVG written as JSON, with the animation added alongside — if you understand SVG files, you will understand these too. If SVG itself is new to you, start with an SVG introduction first (for example MDN’s SVG tutorial) and come back. Why the format is shaped this way: Format principles.
A complete small document
Section titled “A complete small document”Everything the format is, in one small document — a ball that drops with an ease-in-out. The comments mark the two things added on top of plain SVG (JSON itself does not allow comments, so a real file has none):
{ // The root <svg> element — plain SVG, written as JSON "type": "svg", "viewBox": "0 0 400 400",
// ADDED: the playback settings — how long, how many times, what starts it "animator": { "timeline": { "duration": 1000, "iterations": "infinite", "trigger": { "start": "load" } } },
"children": [ { // A plain <ellipse> element with ordinary SVG attributes "type": "ellipse", "cx": 139, "cy": 163, "rx": 64, "ry": 64, "fill": "#007fff85", "stroke": "#003a73",
// ADDED: the element's animation — keyframes for its transform attribute "animate": { "transform": { "keyframes": [ { "time": 0, "value": { "translate": [0, 0] } }, { "time": 1000, "value": { "translate": [0, 147] }, "easing": [0.42, 0, 0.58, 1] } ] } } } ]}Three ideas cover 90 % of the format:
- Every element is a JSON object —
typeholds the SVG tag name, every other key is an SVG attribute, andchildrenis an array of the element’s child elements, nested the same way the tags nest in an SVG file. - Any attribute can be animated by adding keyframes next to it — they go under the
element’s
animatekey, filed by the attribute’s name. The static attribute itself stays where it is, so if you remove everyanimate, a valid static SVG remains. - One place for playback settings — the root
animatorobject holds everything about how the document plays: duration, loops, what starts it.
Schema at a glance
Section titled “Schema at a glance”The whole format as flattened TypeScript-style typings, with comments. First, the smallest document that animates — everything not written in it is a default:
{ "type": "svg", "viewBox": "0 0 100 100", "children": [ { "type": "circle", "cx": 50, "cy": 50, "r": 20, "fill": "#3b82f6", "animate": { "opacity": { "keyframes": [ { "time": 0, "value": 0 }, { "time": 1000, "value": 1 } ] } } } ]}No animator block at all means: a time-driven timeline (no type needed), one iteration of
1000 ms, starting on load and holding the final state. To have your own code start it
instead, say "animator": { "timeline": { "trigger": { "start": "none" } } }.
Every block below also has a name you can import from @pixodesk/svg-animator-core, for when you
write or transform documents in TypeScript rather than by hand:
| Import this | to type this part of the document |
|---|---|
PxAnimatedSvgDocument | the whole file |
PxNode | any element in children; PxSvgNode is the root <svg>, which adds the document keys |
PxAnimatorConfig | the animator block as a whole |
PxTimeline | animator.timeline — what advances the playhead |
PxTrigger | animator.trigger — what starts it |
PxElementAnimation | one element’s animate map; PxKeyframe is one entry of a property’s keyframes, and PxLoop that property’s loop |
PxBinding | one entry of animator.bindings, in a bind-by-id document |
PxDefinitions | animator.definitions; PxAnimationDefinition is one named animation in it, PxGlyphFont one embedded face and PxGlyph one letter outline in that face |
PxEffects | a node’s effects bucket |
PxTransformParts | a transform written in parts; PxTransformValue is one such value and PxVec2 an [x, y] pair |
PxBezierPath | a path outline as bezier segments |
PxScroll | a scroll-driven timeline; PxScrollRangePoint is one end of its range |
PxTimelineEngineSetting · PxTimelineEngine · PxFillMode · PxPlaybackDirection | the timeline’s engine (as written: auto · native · js; as resolved: native · js), fillMode and direction. Every two-or-more-way wire selector is a named constant like these — a const namespace AND the string type of the same name, so PxTriggerStart.click and start?: PxTriggerStart come from one import |
PxTriggerStart · PxMouseOutAction · PxFinishAction | the trigger’s start, mouseOut and finish |
PxScrollKind · PxScrollAxis · PxScrollSource · PxScrollPhase · PxPinAlign | a scroll timeline’s type, axis, source, a range point’s phase, and pin.align |
PxAlongPathMode · PxLoopRepeatAt · PxLoopDirection | a property animation’s alongPathMode, and its loop.repeatAt / loop.direction |
PxStrokeTrimSubPaths · PxCloneWithout · PxMaskType · PxUnits · PxGradientType · PxGradientSpreadMethod · PxPathOverflow · PxLengthAdjust · PxTextPathMethod · PxTextPathSpacing | the effects’ selectors: strokeTrim.subPaths, clone.without, maskedBy.maskType, the mask and gradient units, a gradient’s type and spreadMethod, and textPath’s pathOverflow / lengthAdjust / method / spacing |
// PxAnimatedSvgDocument// Self-contained document: has children — player renders SVG tree and animates it// Bind-by-id document: no children — player animates a pre-existing SVG DOM via animator.bindings// The root <svg> is a NODE too (every node key applies), plus:interface SVG_JSON extends NODE { type: 'svg'; // document root marker id?: string; // DOM id; in a bind-by-id document it locates the pre-rendered element viewBox?: string; // internal coordinate space, e.g. "0 0 700 380" width?: number | string; // rendered size; a string may carry CSS units, e.g. "100%" height?: number | string; [camelCaseDomKey: string]: any; // any SVG attribute under its camelCase DOM name (viewBox, strokeWidth, …), as React spells it; pass-through to DOM
animator?: { // WHAT ADVANCES THE PLAYHEAD — a discriminated object mirroring WAAPI's // DocumentTimeline / ScrollTimeline / ViewTimeline. Timing and the playback // dynamics live INSIDE it; each type carries only the fields that mean // something for it. `type` is optional: absent = 'time' (the wall clock), so the // common case declares nothing. Omitting `timeline` entirely = all defaults. timeline?: | { type?: 'time'; // wall time — something STARTS it (the trigger). OPTIONAL: absent = 'time' engine?: 'auto' | 'native' | 'js'; // WHO RUNS IT (default 'auto'): the browser where it can — WAAPI here, // its ScrollTimeline for scroll/view — else the player's own frame loop; // 'native' = browser only; 'js' = the player's own loop (+ own scroll measurement) frameRate?: number; // target fps for the player's own frame loop ('js', or 'auto' after // falling back to it); uncapped when absent, ignored by WAAPI/RN duration?: number; // length of ONE iteration, ms (default 1000); keyframe times are absolute offsets delay?: number; // wait before start, ms (default 0); negative = skip ahead, e.g. -500 starts from the 0.5 s frame iterations?: number | 'infinite'; // repeat count (default 1); composes with per-property loop (loop-within-loop) fillMode?: 'forwards' | 'backwards' | 'both' | 'none'; // CSS animation-fill-mode; default 'forwards' holds final state direction?: 'normal' | 'reverse' | 'alternate' | 'alternate-reverse'; // default 'normal' trigger?: { start?: 'load' | 'mouseOver' | 'click' | 'none'; // what STARTS it; default 'load'; 'none' waits for play() offScreen?: 'pause' | 'continue' | 'reset'; // while nobody can see it; default 'pause' mouseOut?: 'continue' | 'pause' | 'reset' | 'reverse'; // when the pointer leaves; default 'continue'; read for start 'mouseOver' finish?: 'hold' | 'reset'; // after a NATURAL finish; default 'hold' (keep end state per `fillMode`). // Named for its occasion like its siblings, and to stay clear of the onFinish CALLBACK visibilityThreshold?: number; // how much must be on screen before it may run: 0 = any part, 1 = all of it; default 0.5 visibilityDebounce?: number; // and for how long, ms; default 150, so scrolling straight past starts nothing }; } | { type: 'scroll' | 'view'; // scrubbed: the scroll container's offset ('scroll') or the SVG's // journey through the viewport ('view'); no trigger/delay slots exist here duration?: number; // the keyframe span the scroll range maps onto, ms iterations?: number; // finite only — 'infinite' cannot map onto a range engine?: 'auto' | 'native' | 'js'; // as above — 'auto'/'native' try the browser's ScrollTimeline, 'js' measures itself frameRate?: number; // target fps for the player's own frame loop ('js', or 'auto' after // falling back to it); uncapped when absent, ignored by WAAPI/RN axis?: 'block' | 'inline' | 'x' | 'y'; source?: 'nearest' | 'root'; // type 'scroll' — which scroll container subject?: string; // type 'view' — whose journey: 'parent' | 'scroller' | a CSS selector smoothing?: number; // ms catch-up lag toward the scroll position pin?: boolean | { // hold the canvas on screen while the scroll drives it align?: 'top' | 'center' | 'bottom'; // default 'top' offset?: number; // px offset from the aligned position (default 0) distance?: number; // scroll travel the pin lasts, in viewport heights }; range?: { // the slice mapped onto progress 0..1; // default { start: {phase:'cover', fraction:0}, end: {phase:'cover', fraction:1} } start?: { phase?: PHASE; fraction?: number }; // fraction: 0..1 within the phase end?: { phase?: PHASE; fraction?: number }; }; // PHASE = 'cover' | 'contain' | 'entry' | 'exit' | 'entry-crossing' | 'exit-crossing' };
// reusable definitions, resolved at runtime — materialize (inline) every ref before // handing the document to a player without them. `fonts` is the one in everyday use; // easings / animations are supported but rarely used. definitions?: { easings?: Record<string, [number, number, number, number]>; // name → [x1,y1,x2,y2] (rarely used) animations?: Record<string, Record<string, ANIMATE>>; // name → { propName: ANIMATE } (rarely used) // embedded fonts for glyph-mode text (effects.text.useGlyphs) — renders without // shipping a font file. The KEY is the FACE name, i.e. exactly the node's // `fontFamily` ("Roboto-Light"): outlines belong to one face, and `fontWeight` / // `fontStyle` are attributes on top of it — they never pick a different face. fonts?: Record<string, { fontFamily: string; // the real family, e.g. "Roboto" fontStyle: string; // the face, e.g. "" | "Regular" | "Bold" | "Bold Italic" ascent: number; // in unitsPerEm unitsPerEm: number; // e.g. 1000 glyphs: Record<string, { width: number; pathData: string }>; // keyed by the character }>; };
debugGlobalName?: string; // debug helper: exposes the animator as window[debugGlobalName]
version?: string; // "a.b.c" — the schema the file was written for: a = generation, // b = player schema (this release reads 1.2), c = editor extension // (meta.*, ignored by the player) — see Versioning. Written by the // editor on save, never by the player; absent = unknown
// bind-by-id documents (a pre-rendered SVG + JS export): the elements already exist as // markup, so the document lists WHICH element plays WHICH named animations. // `…With` is the format's spelling for "by name, from `definitions`": the bare key // holds the thing itself (`node.animate` holds keyframes), `<key>With` an array of // names of the same concept (`animateWith` → `definitions.animations`). bindings?: Array<{ target: string; // the element, '#id'-spelled like every element reference animateWith: Array<string>; // names into definitions.animations, applied in order }>; };
// self-contained documents — SVG element tree; its absence makes the document bind-by-id children?: Array<NODE>;}
// One SVG elementinterface NODE { type: string; // SVG element tag: "rect", "g", "path", "ellipse", "use", … id?: string; // DOM id; required for href="#id" refs or as a binding's target [camelCaseDomKey: string]: any; // SVG attrs by camelCase DOM name — cx, cy, r, fill, strokeWidth, fontSize, transform, … (kebab-case is accepted too); pass-through domType?: string; // the `type` ATTRIBUTE of the few elements that have one (feTurbulence, …): // `type` is taken by the tag name, so the attribute travels here textContent?: string; // text content for <text>/<tspan> style?: { [camelCaseCssProperty: string]: string | number }; // inline declarations, camelCase like React's style prop (whiteSpace, pointerEvents); an object only // the normal form is the inline record { propName: ANIMATE }; the string forms // reference definitions.animations by name (rarely used), and can be mixed in an array animate?: Record<string, ANIMATE> | string | Array<string | Record<string, ANIMATE>>; effects?: EFFECTS; // see "Player effects" below; JSON-only — the pre-rendered SVG export materializes these meta?: any; // editor-only (label, shape, …); not rendered, ignored by the player children?: Array<NODE>; // recursive; <g>, <defs>, <symbol>, <text>, <use>, …}
// Player effects — expanded into plain elements when the document loadsinterface EFFECTS { // each part is animatable: raw value | {value} | {keyframes} transformBy?: { translate?: [x,y], rotate?: deg, skew?: deg, scale?: [x,y], origin?: [x,y] }; repeater?: { copies?: number, translate?: [x,y], rotate?: deg, skew?: deg, scale?: [sx,sy] /*per-copy multiplier, compounds v^i*/, origin?: [x,y] }; maskedBy?: { source?: '#id', // the element that becomes the mask maskType?: 'alpha' | 'luminance', maskUnits?: 'userSpaceOnUse' | 'objectBoundingBox', maskContentUnits?: 'userSpaceOnUse' | 'objectBoundingBox', x?: number, y?: number, width?: number, height?: number }; // mask viewport, in maskUnits clipPath?: { pathData?: "M…" | ANIMATE }; // ONE animatable slot: a path string, or ANIMATE ({ value } static, { keyframes } animated) strokeTrim?: { offset?: number, range?: [a,b], subPaths?: 'separate' | 'combined' }; // offset/range animatable clone?: { without?: 'translate', // which part of the SOURCE's own transform is left out: absent = whole element (moves with the source); // 'translate' = stays where the <use> put it, still rotates/scales with the source ('transform' may follow) source?: '#id', // what it clones retime?: { start?, stretch?: number, timeCrop?: [inMs, outMs] } }; // retime is PURE timing fillGradient?: GRADIENT; // paints the fill … strokeGradient?: GRADIENT; // … or the stroke; same settings textPath?: { pathData: string /*inline SVG d*/, pathOverflow?, lengthAdjust?, method?, spacing?, startOffset?, textLength? }; text?: { useGlyphs?: boolean }; // render text from embedded glyph outlines (definitions.fonts)}
// Geometry slots animate like any other slot ({value} | {keyframes});// gradient geometry animation runs on the player's frame loop ('auto' switches to it).interface GRADIENT { type: 'linear' | 'radial'; start?; end?; // linear: the line the gradient runs along center?; radius?; focal?; // radial stops?; // [{ offset, color }, …] — animated: every keyframe carries the whole list gradientUnits?; spreadMethod?; gradientTransform?; // gradientTransform is static}// PxPropertyAnimation — single-property animationinterface ANIMATE { keyframes?: Array<{ time?: number; // ms offset from the start of the document timeline value?: any; // see "Keyframe values" below easing?: string | [number, number, number, number]; // named ref or cubic-bezier tangentOut?: [number, number]; // motion-path delta tangent at this kf tangentIn?: [number, number]; // motion-path delta tangent at this kf }>; value?: any; // optional static baseline (rarely needed — the node's own attribute is the baseline) autoOrient?: boolean; // translate-only: rotate element to face the path tangent alongPathMode?: 'sampled' | 'offsetPath'; // transform-only: how a motion path is rendered — pre-sampled keyframes (default) or CSS offset-path // pre-processes keyframes to fill the timeline duration by repeating a segment // true → default: repeat last segment, cycling forward // independent of timeline.iterations; composes as loop-within-loop loop?: boolean | { segmentCount?: number; // intervals forming the segment; undefined = whole sequence; clamped [1, n-1] repeatAt?: 'start' | 'end'; // which END the repetition fills: 'end' (default, absent) = idle/outro, // 'start' = intro direction?: 'normal' | 'alternate'; // 'normal' (default) = cycle same direction; 'alternate' = pingpong };}The document root
Section titled “The document root”| Field | Type | Meaning |
|---|---|---|
type | always the string "svg" | required — marks the root element of the document |
id | string | the element’s identifier — it becomes the DOM id, and other elements and effects reference the element by it (documents without children rely on these references) |
viewBox | string | the drawing’s coordinate space, exactly as in SVG — e.g. "0 0 700 380" means “the drawing spans 700 × 380 units” |
width · height | number or string | how big the drawing appears on the page — the same width / height you would put on an <svg> tag. Write a plain number (400) for pixels, or a string for anything with a unit: "32px", "100%" |
animator | object | the playback settings — duration, loops, trigger, etc — see Playback settings and Definitions |
children | array of element objects | the nested SVG children tree |
animate · effects · style · meta · textContent · domType | as on any node | the root <svg> is a node too — every node key applies to it |
| any SVG attribute | string or number | any other key is passed through to the rendered <svg> as an SVG attribute (fill, style, …) |
Every element of the SVG tree is written as a JSON object, called a node. The type key
holds the tag name ("rect", "circle", "path", …), the element’s SVG attributes are
ordinary keys next to it, and three optional keys can be added: children (the element’s
child elements), animate (its animation) and effects (its effects):
// A plain SVG <rect>, written as JSON{ "type": "rect", "id": "box", "x": 10, "y": 10, "width": 80, "height": 40, "rx": 6, "fill": "#6366f1", // ADDED: the rect's animation — keyframes for its opacity "animate": { "opacity": { "keyframes": [ { "time": 0, "value": 0 }, { "time": 500, "value": 1 } ] } } }| Field | Meaning |
|---|---|
type | the SVG tag: rect, circle, ellipse, line, path, g, text, tspan, use, symbol, defs, image, mask, clipPath, linearGradient, radialGradient, stop, pattern, marker, filter and the fe* primitives, … |
id | DOM id — required when something references the element (href="#id", maskedBy, a binding’s target — references are always #id-spelled, record keys included). When the player creates the DOM elements, it replaces every id with a fresh one (that is how several copies of one file coexist on a page), so ids need only be unique within the file |
children | nested nodes |
animate | this node’s animations — below |
effects | this node’s effects — Player effects |
style | inline style declarations — an object of camelCase CSS property → value, like React’s style prop (whiteSpace, pointerEvents); an explicit attribute on the node wins |
textContent | text content of <text> / <tspan> |
domType | the type attribute of the few elements that have one (feTurbulence, feColorMatrix, …) — type itself is taken by the tag name; the player writes it back as type |
meta | editor-only data (labels, shape presets, applied effects). Players ignore it, so if the file will only ever be played — never edited in the editor again — this key can be removed — Editor meta and applied effects |
| any other key | an SVG attribute |
Attribute names are the camelCase DOM names — the spelling React uses for SVG
(strokeWidth, fontSize, viewBox, clipPath): that is what the editor writes and what every
example here uses. The player renders them to the standard SVG attribute (stroke-width), and
accepts that kebab spelling on the way in as well. style keys follow the same rule — camelCase CSS
property names, as in React’s style prop (whiteSpace, pointerEvents, mixBlendMode).
Static values are typed: numbers (opacity: 0.5), number lists (strokeDasharray: [16, 16]), strings (fill: "#33b366", viewBox), a transform written as an object with one key per part (transform: { translate: [10, 10], rotate: 45 }) or an SVG transform string. Note that a static
attribute value is written exactly the same way as a keyframe’s value for that attribute —
learn one way of writing values and you know both.
Reserved keys — type, children, animator, animate, effects, meta and
textContent never reach the DOM as attributes: they are instructions for the player, which
turns them into other things — the element itself, its child elements, its text, its
animation.
One name clashes with this rule. A few SVG filter elements (such as <feTurbulence>) have an
attribute that is itself called type — but in a node, the type key is already taken by
the tag name. For these elements, write the attribute under the name domType instead;
when the player creates the element, it turns domType back into a real type attribute:
// In SVG this would be: <feTurbulence type="fractalNoise" baseFrequency="0.05" numOctaves="2" />{ "type": "feTurbulence", "domType": "fractalNoise", "baseFrequency": 0.05, "numOctaves": 2 }Text nodes carry their content in textContent, not as children:
{ "type": "text", "x": 20, "y": 40, "fill": "#111", "fontSize": 18, "textContent": "Hello", "children": [ { "type": "tspan", "dy": 20, "textContent": "second line" } ] }With effects.text.useGlyphs: true the text renders from glyph outlines embedded in
definitions.fonts — no font needed on the viewer’s machine (the editor embeds them for you).
Animating — the animate channel
Section titled “Animating — the animate channel”A node animates through its animate map — one entry per attribute, each holding that
attribute’s keyframes:
// Three attributes of one element, each animated on its own channel"animate": { "opacity": { "keyframes": [ { "time": 0, "value": 0 }, { "time": 1000, "value": 1 } ] }, "fill": { "keyframes": [ { "time": 0, "value": "#3b82f6" }, { "time": 1000, "value": "#ec4899" } ] }, "transform": { "keyframes": [ { "time": 0, "value": { "rotate": 0 } }, { "time": 1000, "value": { "rotate": 360 } } ] }}Before it plays, and after a reset, an element shows its static attributes. An animated
attribute that has no static value beside it rests on its first frame — the player fills
that value in as if you had written it. So the ellipse below sits at 139, 163 before anyone
presses play, not at the origin, even though it states no transform of its own; and the
editor always writes the static value out explicitly ("transform": { "translate": [139, 163] }
next to the keyframes), so its files carry it either way. To rest somewhere other than the
first frame, write the static attribute yourself — an authored value is always left alone.
{ "type": "ellipse", "rx": 64, "ry": 64, "animate": { "translate": { "keyframes": [ { "time": 0, "value": [139, 163] }, { "time": 1000, "value": [139, 310] } ] } } }That is the usual form: an object with one entry per animated attribute. There are also
three shorthand forms, all built on named animations — animations defined once in
definitions.animations (below) and reused by name:
// One named animation, by itself"animate": "fadeIn"
// Several named animations at once"animate": ["fadeIn", "spin"]
// Named animations mixed with an ordinary inline one"animate": ["fadeIn", { "scale": { "keyframes": [ { "time": 0, "value": [1, 1] }, { "time": 1000, "value": [1.5, 1.5] } ] } }]Any attribute takes one of three forms, consistently across the format:
{ fill: '#3b82f6' } // 1. primitive — static{ transform: { value: { translate: [10, 10] } } }// 2. {value} — structured static{ opacity: { keyframes: [ … ] } } // 3. {keyframes} — animatedProperty animation
Section titled “Property animation”| Field | Type | Meaning |
|---|---|---|
keyframes | array | the timeline |
value | same as a keyframe’s value (Keyframe values) | optional static baseline (rarely needed — the static attribute on the node is the baseline) |
loop | true or object | this one property repeats on its own, independent of the whole document’s iterations — Per-property loops |
autoOrient | boolean | translate animations with tangents: rotate the element to face the path — Motion along a path |
alongPathMode | sampled (default) · offsetPath | transform only: how a motion path is rendered — as pre-sampled keyframes, or as CSS offset-path |
Keyframes
Section titled “Keyframes”| Field | Type | Meaning |
|---|---|---|
time | ms | offset from the start of the document timeline |
value | depends on the property — Keyframe values | the property’s value at this time |
easing | [x1, y1, x2, y2] or a name | how the value moves from this keyframe to the next one: a cubic-bezier curve, or the name of a curve defined in definitions.easings |
tangentOut · tangentIn | [dx, dy] | spatial tangents for motion along a path (translate only), relative to this keyframe’s position |
Every key has exactly one spelling — there are no short aliases.
Keyframe values
Section titled “Keyframe values”| Property kind | value | Example |
|---|---|---|
a single number (opacity, r, strokeWidth, rotate, …) | number | 0.5 |
a list of numbers (strokeDasharray, scale, translate) | number array | [80, 40] |
color (fill, stroke, stopColor, …) | CSS color string (or an RGBA number array) | "#ec4899" |
unified transform | an object with one key per transform part — translate, rotate, scale, skew, origin | { "translate": [8, 4], "rotate": 90, "scale": [2, 2] } |
path d | { "pathData": "M…" } (a bare "M…" string is also accepted) | { "pathData": "M0,0 L50,0 L50,50 Z" } |
gradient stops (inside gradient effects) | array of { offset, color } — each keyframe’s value is the complete stop list, every stop with its position and color at that moment | [{ "offset": 0, "color": "#3b82f6" }, { "offset": 1, "color": "#ec4899" }] |
Easing
Section titled “Easing”Easing controls the pace of the change between two keyframes — for example start fast and slow down towards the end. It is written on the keyframe where the movement begins and applies to the movement from that keyframe to the next.
Give it a cubic-bezier curve directly:
{ "time": 0, "value": 0, "easing": [0.33, 0, 0.67, 1] }Or give it a name — the named curve must then be defined in animator.definitions.easings
of the same document:
// on the keyframe{ "time": 0, "value": 0, "easing": "smooth" }
// at the document root"animator": { "definitions": { "easings": { "smooth": [0.42, 0, 0.58, 1] } }}A keyframe with no easing gets linear movement: the value changes at a constant, even pace
all the way to the next keyframe.
Per-property loops
Section titled “Per-property loops”A property can repeat part of its own keyframes to fill the timeline’s duration, independently of
the document’s iterations:
"rotate": { "keyframes": [ { "time": 0, "value": 0 }, { "time": 1000, "value": 360 } ], "loop": true }"scale": { "keyframes": [ { "time": 0, "value": [1, 1] }, { "time": 500, "value": [1.2, 1.2] }, { "time": 1000, "value": [1, 1] } ], "loop": { "segmentCount": 1, "repeatAt": "end", "direction": "alternate" } }| Field | Meaning |
|---|---|
segmentCount | how big the repeated piece is, counted in intervals — an interval is the stretch between two neighbouring keyframes. By default the whole sequence repeats; "segmentCount": 1 repeats only one interval — the last one with repeatAt: "end", the first one with repeatAt: "start". In the example above, scale has three keyframes (two intervals), and only its second half — the shrink back from 1.2 to 1 — keeps repeating |
repeatAt | which end of the timeline the repetition fills. "end" (default): the animation plays through once, then the last intervals repeat until the document’s duration is used up — e.g. a character lands and then keeps breathing. "start": the first intervals repeat first, and the rest of the keyframes play at the end — e.g. a logo pulses for a while and then settles |
direction | "normal" (default) replays in the same direction; "alternate" ping-pongs |
loop: true = repeat the whole sequence, after, forward.
Transforms
Section titled “Transforms”Animate every part together as one transform property. Each keyframe’s value is an
object holding all the parts — translate, rotate, scale — side by side:
// One transform property; every keyframe carries all the parts together"animate": { "transform": { "keyframes": [ { "time": 0, "value": { "translate": [0, 0], "rotate": 0, "scale": [1, 1] } }, { "time": 1000, "value": { "translate": [80, 40], "rotate": 90, "scale": [1.5, 1.5] } }] } }| Part | Type | Notes |
|---|---|---|
translate | [x, y] | how far to move along x and y — plain numbers in the drawing’s coordinates (the viewBox space) |
rotate | number | degrees |
skew | number | skewX degrees, composed between rotate and scale |
scale | [sx, sy] | multipliers: 1 = unchanged, 2 = double size, 0.5 = half |
origin | [x, y] | pivot for rotate / skew / scale — only meaningful alongside one of them |
The parts are applied in this order: translate · +origin · rotate · skewX · scale · −origin.
The same kind of object is also the preferred way to write a static transform
attribute ("transform": { "translate": [100, 100], "rotate": 45 }); an ordinary SVG
transform string is accepted too. Each part can also be animated as its own channel
(animate: { translate, rotate, scale }).
An animated transform writes the element’s one transform attribute, so it overwrites a
static transform on the same element — put a fixed placement on a wrapping <g> instead.
And since all parts share one set of keyframes, they run on one schedule; to give each part
its own timing, use the transformBy effect.
The per-key form (animate: { translate, rotate, scale } — used in the examples below) is also accepted; both produce the same composed transform string at render. A static transform attribute composes under the animated transform (CSS’s own precedence, applied at read): partial keyframe parts inherit the static parts they don’t set, and a single per-key channel next to a static transform keeps that static — transform: {rotate: 45} + an animated translate slides the element while it stays rotated. The one remaining last-write-wins case: several per-key channels animated at once on one node — compose those by nesting <g>s instead.
Motion along a path
Section titled “Motion along a path”Translate keyframes can carry Bézier tangents; the element then moves along the curve, and
autoOrient turns it to face the direction of travel:
"animate": { "transform": { // The element turns to face its direction of travel "autoOrient": true, "keyframes": [ // The tangents bend the straight line between the keyframes into a curve { "time": 0, "value": { "translate": [30, 150] }, "tangentOut": [46, -80] }, { "time": 3000, "value": { "translate": [270, 150] }, "tangentIn": [-46, -80] } ]} }The tangents are the Bézier control points of the curve, written relative to their keyframe’s own position (like the handles of a pen tool).
Shape morphing — animating a <path>’s outline
Section titled “Shape morphing — animating a <path>’s outline”A path’s shape is its d attribute — a string of drawing commands. Animate d and the
shape morphs from one form to the next. One rule: every keyframe’s path must have the
same number of points (in the example below, both shapes have four). Paths drawn in the
editor get this right automatically:
{ "type": "path", "fill": "#f59e0b", "d": "M-50,0 L0,-50 L50,0 L0,50 Z", // The diamond morphs into a square — both shapes have four points "animate": { "d": { "keyframes": [ { "time": 0, "value": { "pathData": "M-50,0 L0,-50 L50,0 L0,50 Z" } }, { "time": 2000, "value": { "pathData": "M-50,-50 L50,-50 L50,50 L-50,50 Z" } } ] } } }Morphing runs on the player’s frame loop (engine: 'auto' switches to it automatically).
Definitions — animator.definitions
Section titled “Definitions — animator.definitions”animator.definitions is where things are defined once, under a name, and used in many
places by that name — instead of repeating the same easing curve or the same animation on
every element that needs it:
"definitions": { "easings": { "smooth": [0.42, 0, 0.58, 1] }, "animations": { "fadeIn": { "opacity": { "keyframes": [ { "time": 0, "value": 0 }, { "time": 2000, "value": 1 } ] } } }, "fonts": { "Roboto": { "fontFamily": "Roboto", "fontStyle": "", "ascent": 928, "unitsPerEm": 1000, "glyphs": { "H": { "width": 722, "pathData": "M100 0V722H190V400H532V722H622V0H532V320H190V0Z" } } } }}| Field | What it holds | How an element uses it |
|---|---|---|
easings | named easing curves | a keyframe writes the name instead of the curve: "easing": "smooth" |
animations | named animations | a node writes the name instead of the keyframes: "animate": "fadeIn" (a bind-by-id document names them in its bindings) |
fonts | embedded fonts: for each FACE, the outline of every letter used, stored under that face’s name ("Roboto-Light" — the same string the node’s fontFamily carries). Each entry’s own glyphs map holds the outlines | a <text> node with effects.text.useGlyphs: true is drawn from these outlines — its fontFamily names the face to use. fontWeight / fontStyle are styling on top of that face, not a way to select another one. No font file is needed on the viewer’s machine |
Animating a pre-rendered SVG
Section titled “Animating a pre-rendered SVG”When the editor saves a pre-rendered SVG + JS animation file, it puts two things into
that one .svg file:
- the markup — the elements, as ordinary SVG, each with an id;
- a shortened JSON document inside the file’s
<script>, which carries only the animations and links them to the markup through those ids.
This section is about that shortened JSON document. It looks like a normal document, with one
difference: it has no children — the elements already exist as markup, so instead of
carrying them again it carries its animations by name in definitions.animations and a list
of bindings, each saying which element (target, by #id) plays which of those animations
(animateWith). You will normally never write such a document yourself; the editor generates it.
import { createAnimator } from '@pixodesk/svg-animator-web';
createAnimator({ container: '#box', doc: { // an empty <div id="box"> on the page type: 'svg', id: '_px_root', animator: { timeline: { duration: 2000 }, definitions: { animations: { fadeIn: { opacity: { keyframes: [ { time: 0, value: 0 }, { time: 2000, value: 1 } ] } }, warm: { fill: { keyframes: [ { time: 0, value: '#0087ff' }, { time: 2000, value: '#ff3b30' } ] } }, } }, bindings: [ { target: '#_px_rect', animateWith: ['fadeIn'] }, // one named animation { target: '#_px_ellipse', animateWith: ['fadeIn', 'warm'] }, // several, applied in order ], },} });A binding carries names only — the keyframes live in definitions.animations, once, however
many elements play them. animateWith is the format’s spelling for “by name, from
definitions”: a bare key holds the thing itself (node.animate holds keyframes), <key>With
holds an array of names of the same concept.
Reference spelling — one rule, everywhere: every element reference is #id-spelled —
href, partOf, every source, and a binding’s target alike.
Units of the values in a document
Section titled “Units of the values in a document”Every number in a document — a keyframe’s time, a duration, a coordinate, an angle — is
written as a plain number, never with a unit after it (no "500ms", no "45deg"). Instead,
each property has one fixed unit that is always understood:
| Value | Unit |
|---|---|
time (time, timeline.duration, timeline.delay, retime.start) | milliseconds |
lengths, coordinates, fontSize in px | plain numbers in the drawing’s coordinates (the viewBox space) — no unit is written |
rotate, skew, angles | degrees |
opacity, trim range / offset, stop offset, visibilityThreshold | a share of the whole, from 0 (none) to 1 (all) |
every scale | a multiplier: 1 = unchanged, 2 = double, 0.5 = half |
retime.stretch | a multiplier of duration: 2 = twice as long (half speed), 0.5 = half as long (double speed) |
frameRate | frames per second |
| easing | cubic-bezier [x1, y1, x2, y2] |
What the player does with an invalid document
Section titled “What the player does with an invalid document”The player is lenient: it renders what it can, never throws on a shape problem and never refuses a document.
- On load, every player checks the whole document against the schema and prints one
console.warnlisting the problems (the first six, then a count), e.g.root.animator.timeline.duratoin: unexpected extra key. The strict parts — theanimatorblock, everyANIMATE, every effect — report any unknown key, so akeyframewherekeyframeswas meant is caught. - Effects are also checked one by one (
validateNodeEffects); each problem is aconsole.warnprefixed with the node’s path, and materialization still does its best. - Unknown attributes on a node pass through to the DOM — that is how any SVG/CSS
presentation attribute works. So a misspelled attribute with a plain value (
"fil": "red") is not an error and is not reported; one whose value is an object is.
To check a generated document before it ships, call validateDocument(doc) — see
Validating a document. It returns the same problem strings, [] when
the document is sound. SCHEMA.json is a JSON Schema generated from the same
runtime schemas (node scripts/gen-schema-json.mjs), for tools that validate JSON themselves.
Examples
Section titled “Examples”Five complete documents, one idea each.
Self-contained, clock timeline:
{ "type": "svg", "viewBox": "0 0 400 400", "animator": { "timeline": { "duration": 1000, "iterations": "infinite", "trigger": { "start": "load" } } }, "children": [ { "type": "ellipse", "fill": "#007fff85", "rx": 64, "ry": 64, "animate": { "translate": { "keyframes": [ { "time": 0, "value": [139, 163] }, { "time": 1000, "value": [139, 310] } ] } } } ]}Named definitions + unified transform:
{ "type": "svg", "viewBox": "0 0 600 400", "animator": { "timeline": { "duration": 2000, "direction": "alternate", "iterations": "infinite" }, "definitions": { "easings": { "smooth": [0.42, 0, 0.58, 1] }, "animations": { "fadeIn": { "opacity": { "keyframes": [ { "time": 0, "value": 0 }, { "time": 2000, "value": 1 } ] } } } } }, "children": [ { "type": "rect", "x": 40, "y": 40, "width": 120, "height": 90, "fill": "#6366f1", "animate": "fadeIn" }, { "type": "path", "id": "star", "d": "M300,60L340,140L260,140z", "fill": "#f59e0b", "animate": { "transform": { "keyframes": [ { "time": 0, "value": { "translate": [0, 0], "rotate": 0, "scale": [1, 1] } }, { "time": 2000, "value": { "translate": [80, 40], "rotate": 90, "scale": [1.5, 1.5] }, "easing": "smooth" } ] } } } ]}Bind-by-id (no children — animates a pre-existing SVG DOM; each binding names its element
by #id and the animations it plays by name — above):
{ "type": "svg", "id": "heroSvg", "animator": { "timeline": { "duration": 3000, "iterations": "infinite" }, "definitions": { "animations": { "spin": { "rotate": { "keyframes": [ { "time": 0, "value": 0 }, { "time": 3000, "value": 360 } ] } }, "spinBack": { "rotate": { "keyframes": [ { "time": 0, "value": 0 }, { "time": 3000, "value": -360 } ] } } } }, "bindings": [ { "target": "#gear-big", "animateWith": ["spin"] }, { "target": "#gear-small", "animateWith": ["spinBack"] } ] }}Effects (repeater + animated stroke trim + gradient):
{ "type": "svg", "viewBox": "0 0 400 200", "animator": { "timeline": { "duration": 1000 } }, "children": [ { "type": "path", "d": "M20,100C60,20,140,20,180,100", "stroke": "#000000", "fill": "none", "effects": { "repeater": { "copies": 3, "translate": [90, 0] }, "strokeTrim": { "offset": { "keyframes": [ { "time": 0, "value": 0 }, { "time": 1000, "value": 1 } ] }, "range": [0, 0.6] }, "fillGradient": { "type": "linear", "start": [0, 0], "end": [100, 0], "stops": [ { "offset": 0, "color": "#ff0000" }, { "offset": 1, "color": "#0000ff" } ] } } } ]}Scroll-driven (scrubbed by the SVG’s journey through the viewport):
{ "type": "svg", "viewBox": "0 0 400 400", "animator": { "timeline": { "type": "view", "duration": 3000, "range": { "start": { "phase": "entry", "fraction": 0 }, "end": { "phase": "exit", "fraction": 1 } } } }, "children": [ { "type": "rect", "width": 80, "height": 80, "fill": "#10b981", "animate": { "translate": { "keyframes": [ { "time": 0, "value": [0, 0] }, { "time": 3000, "value": [320, 0] } ] } } } ]}Player effects
Section titled “Player effects”An effect is a shortcut. Everything an effect does could be written out by hand with
plain elements and attributes — a mask as a <mask> element, five copies of a shape as five
elements — but that means a lot of repeated markup. An effect says the same thing in one
short declaration on the element: “mask me with that circle”, “repeat me five times, each
copy 80 further right”. When a JSON document loads, the player expands each effect into the
plain elements and attributes it stands for. (This applies to JSON only — in a pre-rendered
SVG export the editor has already done the expanding.)
// A plain SVG <rect> with ordinary attributes{ "type": "rect", "x": 0, "y": 0, "width": 40, "height": 40, "fill": "#3b82f6", // ADDED: two effects — the player turns them into real elements when the file loads "effects": { "transformBy": { "translate": [50, 50], "rotate": 30 }, "repeater": { "copies": 5, "translate": [80, 0], "rotate": 15 } } }Effect attribute values — static or animated. Many of an effect’s attributes can be
animated, just like an element’s attributes. In the tables below their type is written as a
union, e.g. number | Animated<number> — the attribute accepts either form:
- a static value —
"rotate": 30(also accepted wrapped in an object:"rotate": { "value": 30 }) - an
Animated<…>value — keyframes written exactly like in an element’sanimate:"rotate": { "keyframes": [ … ] }, optionally with"loop"
When an element has several effects, the player applies them in a fixed order — the
Applied column below. How you order the keys inside effects makes no difference:
| Applied | Effect | What it does |
|---|---|---|
| 1 | text | glyph-outline text rendering |
| 2 | textPath | text along a path |
| 3 | fillGradient / strokeGradient | gradient paint with animatable stops and geometry |
| 4 | strokeTrim | reveal or hide a stroke progressively along its path |
| 5 | repeater | N copies, each stepped by a delta |
| 6 | maskedBy | mask by another element |
| 7 | clipPath | clip to a (possibly animated) path |
| 8 | transformBy | per-part transforms with independent timing |
| 9 | clone | <use> semantics: what it copies, and re-timing |
For example, repeater + transformBy on one element always means “repeat, then transform
the whole row”, because repeater (5) is applied before transformBy (8). For “transform
each copy, then repeat”, put the transformBy on a child and the repeater on the parent
group.
1 — text
Section titled “1 — text”Draws a <text> element from letter outlines stored in the document itself
(definitions.fonts) instead of using a font: the text looks identical on every machine,
and no font file needs to be installed or loaded.
| Field | Type | Meaning |
|---|---|---|
useGlyphs | boolean | render the text from the glyph outlines in definitions.fonts — self-contained, identical on every machine, no font loading |
{ "type": "svg", "viewBox": "0 0 400 100", "animator": { "timeline": { "duration": 1000 }, "definitions": { // The outlines the text is drawn from — one entry per letter used // (paths shortened here; the editor writes the real ones) "glyphs": { "Roboto": { "fontFamily": "Roboto", "fontStyle": "", "ascent": 928, "unitsPerEm": 1000, "glyphs": { "H": { "width": 722, "pathData": "M…" }, "e": { "width": 556, "pathData": "M…" }, "l": { "width": 222, "pathData": "M…" }, "o": { "width": 556, "pathData": "M…" } } } } } }, "children": [ { "type": "text", "x": 20, "y": 60, "fontFamily": "Roboto", "fontSize": 32, "textContent": "Hello", // Draw "Hello" from the embedded Roboto outlines instead of loading the font "effects": { "text": { "useGlyphs": true } } } ]}The editor embeds the used glyphs when you switch a text to glyph mode. Combined with
textPath, the glyphs are laid along the path directly.
2 — textPath
Section titled “2 — textPath”Put this on a <text> element to lay its text along a curved path — and, if you want, to
move the text along that path over time.
The text is rendered one of two ways — as browser text (a native SVG <textPath>, using
a font), or as glyph text (from embedded outlines, when the element also has
effects.text.useGlyphs: true). Not every field applies to both; the Applies to column
says which:
| Field | Type | Meaning | Applies to |
|---|---|---|---|
pathData | path string | the path geometry (inline — no separate element needed) | browser text, glyphs |
startOffset | number | Animated<number> | where the text starts along the path (SVG spec) | browser text, glyphs |
textLength | number | Animated<number> | stretch / squeeze the text to this length (SVG spec) | browser text, glyphs |
lengthAdjust | spacing (default) · spacingAndGlyphs | how textLength is reached: by changing the space between glyphs only, or by stretching the glyphs too (SVG spec) | browser text only |
method | align (default) · stretch | each glyph is rotated to sit on the path, or the glyphs themselves are bent to follow its curve (SVG spec) | browser text only |
spacing | auto · exact (default) | exact places glyphs strictly by the SVG layout rules; auto lets the renderer adjust the spacing to look better on curves (SVG spec) | browser text only |
pathOverflow | extend (default) · clip | glyphs past the end of an open path continue along the tangent, or disappear | browser text, glyphs |
{ "type": "text", "fill": "#111", "fontSize": 18, // Lay the text along the curve, and slide it 260 units along the path over 2 s "effects": { "textPath": { "path": "M20,100 Q150,20 280,100", "startOffset": { "keyframes": [ { "time": 0, "value": 0 }, { "time": 2000, "value": 260 } ] } } }, "children": [ { "type": "tspan", "textContent": "animated text on a path" } ] }3 — fillGradient / strokeGradient
Section titled “3 — fillGradient / strokeGradient”Paints the element’s fill (or its stroke) with a color gradient, and lets the gradient’s
colors and geometry animate. Under the hood it generates a <linearGradient> /
<radialGradient> element and points the fill (or stroke) at it. Both effects have the
same settings; the only difference is which of the two attributes is painted.
| Field | Type | Meaning |
|---|---|---|
type | linear · radial | a gradient along a line from start to end, or one spreading out from a center |
start · end | [x, y] | Animated<[x, y]> | the line the linear gradient runs along — SVG’s x1/y1/x2/y2 (SVG <linearGradient> spec) |
center · radius · focal | [x, y] | Animated<[x, y]> · number | Animated<number> · [x, y] | Animated<[x, y]> | the radial gradient’s center, radius and focal point — SVG’s cx/cy, r, fx/fy (SVG <radialGradient> spec) |
stops | array of { offset, color } | Animated<array of { offset, color }> | the gradient’s color stops — each becomes an SVG <stop> element. When animated, each keyframe’s value is the complete stop list — every stop with its position and color at that moment — and every keyframe must have the same number of stops |
gradientUnits | objectBoundingBox (default) · userSpaceOnUse | which coordinates start, end, center, radius, focal are in: positions across the element’s own box (0 = its left / top edge, 1 = its right / bottom edge), or the drawing’s own coordinates (SVG spec) |
spreadMethod | pad (default) · reflect · repeat | what to paint beyond the last stop: extend the end color, mirror the gradient back, or start it over (SVG spec) |
gradientTransform | string | static only (SVG spec) |
{ "type": "rect", "x": 0, "y": 0, "width": 200, "height": 120, // A horizontal gradient; its two colors cross-fade to new ones over one second "effects": { "fillGradient": { "type": "linear", "start": [0, 0], "end": [200, 0], "stops": { "keyframes": [ { "time": 0, "value": [ { "offset": 0, "color": "#3b82f6" }, { "offset": 1, "color": "#ec4899" } ] }, { "time": 1000, "value": [ { "offset": 0, "color": "#10b981" }, { "offset": 1, "color": "#f59e0b" } ] } ] } } } }Animated stop colors work everywhere; animated stop offsets and geometry need the frame
loop (engine: 'auto' switches for you). CSS exports can animate stop colors only.
4 — strokeTrim
Section titled “4 — strokeTrim”Shows only a part of the stroke along the path — a line that draws itself, or erases itself. Works
by generating stroke-dasharray / stroke-dashoffset; the path geometry and fill are
untouched (unlike Lottie’s trim paths, which cut the shape itself).
| Field | Type | Meaning |
|---|---|---|
range | [start, end] | Animated<[start, end]> | which part of the stroke is visible, as two positions along the path: 0 is the start of the path, 1 its end — [0, 0.5] shows the first half |
offset | number | Animated<number> | slides that visible part along the path, as a share of its length: 0.25 moves it a quarter of the way |
subPaths | separate (default) · combined | what those positions are measured against: each sub-path against its own length, or all sub-paths chained into one so the visible part slides across them |
{ "type": "path", "d": "M 30 360 C 130 290 230 420 330 350", "stroke": "#ef4444", "strokeWidth": 3, "fill": "none", // The stroke draws itself: the visible part grows from nothing to the full length in 2 s "effects": { "strokeTrim": { "range": { "keyframes": [ { "time": 0, "value": [0, 0] }, { "time": 2000, "value": [0, 1] } ] } } } }The trim can also be put on a group. Then it applies to every path inside the group, and
subPaths decides how:
"separate"— every path inside is trimmed on its own, each against its own length;"combined"— all the paths are chained into one long line, and the visible part slides across them as a whole.
5 — repeater
Section titled “5 — repeater”Repeats the element: the player creates copies real copies, each one shifted, rotated or
scaled a step further than the one before — like rubber-stamping a shape across the page.
If the element is animated, every copy carries the same animation.
| Field | Type | Meaning |
|---|---|---|
copies | number | static — the count cannot animate |
translate | [x, y] | Animated<[x, y]> | per-copy step |
rotate | number | Animated<number> | per-copy degrees |
skew | number | Animated<number> | per-copy skewX degrees |
scale | [sx, sy] | Animated<[sx, sy]> | per-copy multiplier (0.85 = each copy 85 % of the previous) |
origin | [x, y] | Animated<[x, y]> | pivot for the per-copy rotate / scale |
{ "type": "rect", "x": 0, "y": 0, "width": 30, "height": 30, "fill": "#6366f1", "animate": { "opacity": { "keyframes": [ { "time": 0, "value": 1 }, { "time": 1000, "value": 0.2 } ] } }, // Four copies, each 50 further right; the per-copy rotation step grows from 0° to 20° "effects": { "repeater": { "copies": 4, "translate": [50, 0], "rotate": { "keyframes": [ { "time": 0, "value": 0 }, { "time": 1000, "value": 20 } ] } } } }6 — maskedBy
Section titled “6 — maskedBy”Shows this element only where another element is: that other element becomes the mask.
Under the hood the player builds a <mask> from it and applies it to this element.
| Field | Type | Meaning |
|---|---|---|
source | "#id" | the element that becomes the mask |
maskType | alpha · luminance (default) | how the source’s pixels become mask values (CSS spec) |
maskUnits · maskContentUnits | userSpaceOnUse · objectBoundingBox | SVG’s mask coordinate systems — omitted, maskUnits is objectBoundingBox and maskContentUnits is userSpaceOnUse, as in SVG (SVG spec, maskContentUnits) |
x · y · width · height | numbers | the area the mask covers, in user units; leave all four out for SVG’s default (-10%,-10%,120%,120% — SVG <mask> spec). A 0 is a real value, not “absent” |
// The element that will become the mask — a growing circle{ "type": "defs", "children": [ { "type": "circle", "id": "spot", "cx": 100, "cy": 100, "r": 80, "fill": "#fff", "animate": { "r": { "keyframes": [ { "time": 0, "value": 20 }, { "time": 1000, "value": 120 } ] } } } ] },// The element being masked{ "type": "rect", "x": 0, "y": 0, "width": 200, "height": 200, "fill": "#ec4899", // Use the circle as this rect's mask: the rect shows only where the circle is "effects": { "maskedBy": { "source": "#spot", "maskType": "alpha" } } }7 — clipPath
Section titled “7 — clipPath”Clips the element by the given path — which can be animated. It simply creates a usual
<clipPath>
for this element and links the two.
| Field | Type | Meaning |
|---|---|---|
pathData | path string | Animated<path string> | static "M…", or { "keyframes": [ { "time", "value": { "pathData": "M…" } } ] } |
// The visible area widens from a narrow strip to the full 200 × 200 square"effects": { "clipPath": { "pathData": { "keyframes": [ { "time": 0, "value": { "pathData": "M0,0 L20,0 L20,200 L0,200 Z" } }, { "time": 1000, "value": { "pathData": "M0,0 L200,0 L200,200 L0,200 Z" } }] } } }8 — transformBy
Section titled “8 — transformBy”Lets each part of a transform — translate, rotate, scale, skew — animate on its own
schedule, with its own keyframe times and easing. A plain transform animation cannot do
that: all its parts share one set of keyframes. Under the hood, the effect wraps the element
in one group per part, and each group animates independently.
| Field | Type | Meaning |
|---|---|---|
translate | [x, y] | Animated<[x, y]> | how far to move along x and y — plain numbers in the drawing’s coordinates |
rotate | number | Animated<number> | degrees |
skew | number | Animated<number> | skewX degrees |
scale | [sx, sy] | Animated<[sx, sy]> | multipliers: 1 = unchanged, 2 = double size, 0.5 = half |
origin | [x, y] | Animated<[x, y]> | pivot |
// Two parts on their own timelines: move during the first half second, then spin during the next"effects": { "transformBy": { "translate": { "keyframes": [ { "time": 0, "value": [0, 0] }, { "time": 500, "value": [200, 0] } ] }, "rotate": { "keyframes": [ { "time": 500, "value": 0 }, { "time": 1000, "value": 360 } ] }} }9 — clone
Section titled “9 — clone”Shows copies of one animated element at several places — like a rubber stamp: draw a wheel
once, stamp it three times, and each copy can play on its own schedule. The copies are
ordinary SVG <use>
elements; the effect on each <use> says what it copies (source) and, optionally,
re-times that copy’s animation (retime — start later, play slower, show only for a while).
It is an effect, rather than a plain <use>, because those things need real copies: the
player materializes each clone into its own elements with its own timing.
| Field | Type | Meaning |
|---|---|---|
source | "#id" | the source element / symbol (the <use> also keeps its normal href) |
without | translate (optional) | leave the field out for a direct copy of the whole element; translate copies the source’s content WITHOUT its own outer position |
retime.start | ms | shift the source’s internal timeline |
retime.stretch | a multiplier of duration | 2 = twice as long (half speed), 0.5 = half as long (double speed) |
retime.timeCrop | [inMs, outMs] | show the instance only between these two times of the document timeline (materialized as a wrapping <g> with an opacity gate) |
// The source: a spinning-wheel symbol with its own one-second animation{ "type": "defs", "children": [ { "type": "symbol", "id": "wheel", "viewBox": "0 0 100 100", "children": [ { "type": "circle", "cx": 50, "cy": 50, "r": 40, "fill": "none", "stroke": "#0087ff", "strokeWidth": 8, "strokeDasharray": "40 20", "animate": { "rotate": { "keyframes": [ { "time": 0, "value": 0 }, { "time": 1000, "value": 360 } ] } } }] } ] },// An exact copy of the wheel{ "type": "use", "href": "#wheel", "x": 0, "y": 0, "effects": { "clone": { "source": "#wheel" } } },// A copy that starts 0.5 s later and spins at half speed{ "type": "use", "href": "#wheel", "x": 120, "y": 0, "effects": { "clone": { "source": "#wheel", "retime": { "start": 500, "stretch": 2 } } } },// A copy shown only between 1 s and 2 s of the document timeline{ "type": "use", "href": "#wheel", "x": 240, "y": 0, "effects": { "clone": { "source": "#wheel", "retime": { "timeCrop": [1000, 2000] } } } }Symbols with their own animation length are how the editor builds reusable animated components; instances re-time them freely.
Editor meta and applied effects
Section titled “Editor meta and applied effects”The editor app writes its own information into a dedicated field that every node can
carry, called meta. It holds things like element labels, shape presets, and the
settings of applied effects.
Players never read this field: a file with no meta at all plays exactly the same. This
page explains what the editor puts there and why, so you can make sense of a meta block you
find in a file — or know what you can safely leave out when writing a file by hand.
Where it lives
Section titled “Where it lives”Every node may carry a meta object. In the JSON format it sits on the node as node.meta;
in a pre-rendered SVG the same object is written into a per-element data-px-meta attribute
(Meta in pre-rendered SVG). One read pipeline handles both.
// A plain SVG path — this is what every player draws{ "type": "path", "id": "star", "d": "M50,122L78,172.4L22,172.4L50,122z", // ADDED: editor-only data — players skip `meta` entirely "meta": { "label": "Triangle", "appliedEffects": { "shape": { "preset": { "type": "polygon", "points": 3, "radius": 30 } } } } }The fields
Section titled “The fields”| Field | Which elements carry it | What it holds |
|---|---|---|
label | any element | the display name shown in the editor’s element tree |
appliedEffects | a plain node | this node’s own effects, already applied — Applied effects |
effectsHost | the host of expanded parts, pre-rendered SVG only | some effects turn one drawn element into several written elements (a repeater becomes its copies) — its “expanded parts”. This field sits on the expansion’s outermost element (its host) and holds { coreId?, appliedEffects }: all the effects the drawn element had, so the editor can fold the parts back into that one element — Expanded parts |
partOf | every element derived by that expansion, pre-rendered SVG only | the counterpart of effectsHost: each element the expansion produced carries "#hostId" pointing back at the host element that holds the effectsHost field, so the whole unit can be found from any of its parts |
runtime | root <svg> only | how the animation code was generated: { useCssAnimation, useJsTriggers, externalJs, unoptimizedJs } — the export-format choices, not the animation |
animator | root <svg>, pre-rendered SVG only | the playback settings; in JSON they are the top-level animator instead (read more) |
timeline | <symbol> only | { duration } — the symbol’s own animation length, ms |
lineSpacing | text line <tspan>s from the second line on | the Auto line-height multiplier the materialized y was computed from |
animate | any element, pre-rendered SVG only | the node’s keyframes, so a CSS export can be re-opened; in JSON this is the node’s own animate |
Applied effects
Section titled “Applied effects”Sometimes the editor materializes an effect: it writes the effect’s finished result
straight into the node’s ordinary attributes — a star preset becomes path data, rounded
corners become the rounded path. The drawing still looks right, but the effect itself is no
longer in it. To keep the file editable, the editor saves the effect’s settings in
meta.appliedEffects: a record of what was applied.
As a result, an effect description can sit in one of two places, and the place says what it means:
| Meaning | Which effects can appear | |
|---|---|---|
node.effects | apply these. The player reads this when the document loads and applies the effects — Player effects | the player effects: text, textPath, fillGradient, strokeGradient, strokeTrim, repeater, maskedBy, clipPath, transformBy, clone |
node.meta.appliedEffects | these were already applied. The result is in the node’s ordinary attributes; the settings are kept for the editor, so that when it opens the file it can read the effect back as an effect — not just its materialized result | the same names, plus keys only the editor knows: shape, combinedPath, and widened text / clone — listed below |
The player never reads appliedEffects, and the editor never re-applies it. Editing a value
in appliedEffects by hand changes nothing on screen — the materialized result is what plays.
Where appliedEffects matters is when the editor opens the file again: it reads each entry
and collapses the materialized result back into the editable effect it came from — a star
preset becomes a star with a radius handle again, not a frozen path. The entries use the same
names as the effects in node.effects, plus a few keys only the editor knows:
shape— where the path came from, so the file re-opens as a star with a radius handle, not as a fixed path. The finished outline always goes intonode.d(ornode.animate.dwhen it animates);shapekeeps the source:preset— a ready-made shape described by a few settings (radius, number of points, roundness, …):star,polygon,spiral,arc,wave,arrow,heart,cross,frame,cog,crescent,tear,eye,trapezoid. The settings can carry keyframes; only the structure (say, a polygon’s side count) is fixed.path— the original path, when a modifier (roundedcorners) was applied to it.
text— widened withfontSourceandcontent, the payload that lets glyph-rendered text be edited as text again.clone— widened with thewidth/heightof a materialized<use>.combinedPath: true— an identity effect the writer adds besidestrokeTrimwhen it had to split a multi-sub-path shape into a<g>of one<path>each; it tells the reader to join them back into one shape.
"meta": { "appliedEffects": { "shape": { "preset": { "type": "polygon", "points": 6, "radius": 40, "startAngle": 0, "roundness": { "keyframes": [ { "time": 0, "value": 0 }, { "time": 1000, "value": 12 } ] } } }} }Some effects expand one drawn element into SEVERAL written elements — that expansion exists only in pre-rendered SVG files and is documented there: Expanded parts — host / core / part.
Core library — @pixodesk/svg-animator-core
Section titled “Core library — @pixodesk/svg-animator-core”Use the core when you need to validate, transform or sample a document without rendering it — in a build step, a test, a server, or a tool of your own. It is the platform-neutral heart of every player: the document schema, the effect materializers, the interpolation engine and the path sampler, with no DOM dependency. It is also what makes the web player and the React Native player produce identical values from the same document.
Do I need it?
Section titled “Do I need it?”Usually no — install a player instead; each depends on the core and re-exports what you need. Install the core directly when you work with documents rather than playback: validating them, transforming them, flattening them for a renderer of your own, or computing values at a given time without rendering anything.
npm install @pixodesk/svg-animator-coreValidating a document
Section titled “Validating a document”Validate a document before it reaches a player — in a build step, a test, or a tool that accepts files from users:
import { isPxDocument, isValidPxDocument, PxAnimatedSvgDocumentSchema, type PxValidationContext } from '@pixodesk/svg-animator-core';import { readFile } from 'node:fs/promises';
const json = JSON.parse(await readFile('animation.json', 'utf8')); // Node — or fetch() in a browser
isPxDocument(json); // cheap shallow gate — is this a Pixodesk document at all?isValidPxDocument(json); // { valid, errors } — full schema
// per-field diagnosticsconst ctx: PxValidationContext = { errors: [], warnings: [], strict: true };if (!PxAnimatedSvgDocumentSchema.isValid(json, ctx, [])) console.error(ctx.errors);// → ["children[0].effects.strokeTrim.range: no union member matched for value 5"]| Mode | Question it answers | Unknown keys |
|---|---|---|
| default | is this document usable? — what the players accept | ignored (forward-compatible) |
strict: true | is this document exactly well-formed? | reported as errors |
Use the default in production readers and strict in tests and tooling.
validateDocument(doc) checks the whole document strictly and returns problem strings (path: what is wrong), empty when sound; validateNodeEffects(doc) checks just the effects buckets and returns warning strings.
// Check a generated document before it shipsimport { validateDocument } from '@pixodesk/svg-animator-core'; // also exported by the web player
const problems = validateDocument(doc); // [] when the document is soundif (problems.length) throw new Error(problems.join('\n'));Reading a document
Section titled “Reading a document”getAnimatorConfig(doc), getChildren(doc), getBindings(doc) and getDefinitions(doc) read
the animator block, the element tree, the bindings and the definitions of a document without
knowing where they sit — reach for them over doc.animator?.… in a tool of your own.
Flattening a document
Section titled “Flattening a document”materializeAllInTree(doc, engine) turns a document into a flat tree any renderer can walk:
- Effects — every
node.effectsbecomes real nodes, wrappers and defs. - Loops — each property’s
loopis expanded into explicit keyframes. - Motion paths — tangented
transformkeyframes andautoOrientare sampled into plain{ translate, rotate }keyframes. - Animated
<use>— replaced by a<g>with a deep clone and fresh ids. - Rest poses — an animated property with no static value gets the value of its first
frame as a plain attribute. At rest — not yet played, or cancelled — a player shows the
static document, and a node an effect generated (a
transformBywrapper, a mask’s inverse chain, a glyph on a path) has no static value of its own: without this step it sits at the identity until something plays. So does a hand-written node animating one individual channel (translate,rotate,scale,skew) — the form these very examples use: its pose goes intotransform, as the parts record the editor writes, because that is the one slot both engines write such a channel to. The value is what the engine itself writes at the first frame, never “the first keyframe” (keyframes may begin before time 0). An authored static value is left alone, and the step never changes the animation — only the frame shown at rest: the engine is run again with the poses in place, and a pose that changes anything it writes, at any point of the iteration, is taken back.
Steps 3–4 run when engine is native. Pass native for any renderer without live <use>
propagation (including react-native-svg); js only for the DOM, which resolves <use>
natively.
import { materializeAllInTree, generateNewIds, calcAnimationValues, normalizeBindings, PxTimelineEngine } from '@pixodesk/svg-animator-core';import doc from './bouncing-ball.json';
const flat = generateNewIds(materializeAllInTree(doc, PxTimelineEngine.native));
// values at any time, no renderer involvedfor (const binding of normalizeBindings(flat, PxTimelineEngine.js) ?? []) { const values = calcAnimationValues(binding.animate, 500); // t = 500 ms console.log(binding.id, values); // → ball { transform: 'translate(200,129.65)' } (the bouncing ball, half-way down)}This is exactly how the React Native player precomputes its tracks and how the web frame loop renders each tick.
Writing your own player
Section titled “Writing your own player”Implement PxPlatformAdapter and hand it to createAdapterAnimator; the engine
handles timing, delay, direction, iterations, fill, playback rate and the lifecycle callbacks,
then calls you with plain attribute writes:
import { createAdapterAnimator, materializeAllInTree, generateNewIds, PxTimelineEngine, type PxPlatformAdapter } from '@pixodesk/svg-animator-core';import doc from './bouncing-ball.json';
const flatDoc = generateNewIds(materializeAllInTree(doc, PxTimelineEngine.js));
const adapter: PxPlatformAdapter = { isConnected: () => true, setAttribute: (id, attrName, value) => { /* apply to your element */ },};
const api = createAdapterAnimator(flatDoc, adapter, { onFinish: () => console.log('done') });api.play();Frame scheduling uses requestAnimationFrame when it exists and falls back to setTimeout, so
the engine runs in browsers, React Native and test environments.
Reference
Section titled “Reference”The calls above, and the rest of what you would call directly, in signature form. Marks: ● user-facing · ○ advanced document tooling · ▪ internal — see the API at a glance.
// ○ Run the whole materialization pipeline: effects → loops → motion paths →// <use> instances → rest poses, in the canonical order. This is exactly what the player// runs internally, so a document flattened here plays identically — the way// to feed a renderer that has no effects support. `resolveTimelineEngine`// turns a document's `timeline.engine` into this argument.function materializeAllInTree( doc: PxAnimatedSvgDocument, engine: 'native' | 'js', options?: { motionPath?: MotionPathMaterializationOptions },): PxAnimatedSvgDocument;
// ○ One stage of it: `node.effects` → plain renderable nodes (+ generated defs).function materializeNodeEffects(root: PxNode): ApplyResult;
// ● The whole-document check: the strict wire schema (undeclared keys included)// plus every `effects` bucket. Returns human-readable problems (`path: what is// wrong`), empty when the document is sound; never throws. Every player runs the// same check on load and prints the problems as one console warning — call this// before shipping a document instead of relying on the console.function validateDocument(doc: unknown, options?: { strict?: boolean }): Array<string>;// `strict` (default true) rejects keys the schema does not declare — right before you ship.// `{ strict: false }` tolerates them, which is what a READER wants: an unknown key usually// means a newer writer — worth a warning, never a refusal.
// ○ Check every `node.effects` bucket against the schema, depth-first. Returns// human-readable warnings (each prefixed with the node's path) and never// throws; the players run this on load and log whatever comes back.function validateNodeEffects(root: PxNode, options?: { strict?: boolean }): Array<string>;
// ● Per-instance playback override, shared by every player. `patch` is a// deep-partial of the document's `animator` block; objects merge key by key,// values replace, and `null` DELETES a key (restoring the default its absence// means). Pure — the document is not modified; the result shares every// untouched subtree by reference. Warnings say what could not be applied// (e.g. clock-only keys aimed at a scroll timeline).function applyAnimatorConfig( doc: PxAnimatedSvgDocument, patch: PxAnimatorConfigPatch, options?: { resetTimeline?: boolean }, // start from the player's defaults; keeps): { doc: PxAnimatedSvgDocument; warnings: Array<string> }; // definitions/bindings
// ○ The same merge one level down, on the config object itself.function mergeAnimatorConfig( base: PxAnimatorConfig | undefined, patch: PxAnimatorConfigPatch,): PxAnimatorConfigMergeResult;
// ○ Folds the four shortcuts (duration/delay/iterations/start) into a patch and// parses the JSON-string form. A shortcut wins over the same key in `timeline`.// This is what every player calls before `applyAnimatorConfig`.function foldTimelineOverride( timeline: PxTimelinePatch | string | undefined, shortcuts: PxTimelineShortcuts,): PxAnimatorConfigPatch | undefined;
// ○ The schema version — see Versioning, below.const PX_WIRE_SCHEMA_VERSION: '1.2'; // the schema this build readsfunction readWireVersion(doc: unknown): PxWireVersion | undefined; // animator.version (or meta.animator.version)function convertWireDocument(doc: unknown): PxWireConversionResult; // up to this schema; never refuses, never mutatesfunction downgradeWireDocument(doc: unknown, target: PxWireVersion): PxWireDowngradeResult; // all or nothing
// ○ Deep-clone a document with fresh ids and internal references rewritten —// what you need before putting the same animation on a page twice.function generateNewIds(doc: PxAnimatedSvgDocument): PxAnimatedSvgDocument;
// ○ Playback on a non-DOM target: implement the adapter, get the frame-loop engine.function createAdapterAnimator( doc: PxAnimatedSvgDocument, adapter: PxPlatformAdapter, callbacks?: PxEngineCallbacks,): PxAnimatorApi;
interface PxPlatformAdapter { isConnected(): boolean; // is the target still mounted setAttribute(id: string, attrName: string, value: string): void;}
// ● The one rule that turns a component's control props into a decision, so React,// Vue and React Native cannot answer it three ways. Most specific first:// `progress` / `time` → `play` / `pause` → `autoplay` → `static`. Props from two// tiers set together come back as ready-made warning sentences; `apiRef` never// changes the mode, being a handle rather than an instruction.function resolveControlMode(props: PxControlProps): PxResolvedControlMode;
// ● True when that mode has to take the document's own trigger over — every mode// except `autoplay`, INCLUDING `static`: a component given no control props at// all renders the first frame and waits, so it forces `start: 'none'`.function controlModeTakesOverTrigger(mode: PxControlMode): boolean;Everything else this package exports
Section titled “Everything else this package exports”| Group | Symbols | |
|---|---|---|
| Wire types | PxAnimatedSvgDocument, PxNode, PxSvgNode, PxAnimatorConfig, PxTimeline, PxTrigger, PxElementAnimation, PxPropertyAnimation, PxKeyframe, PxLoop, PxBinding, PxDefinitions, PxEffects, PxTransformParts, PxBezierPath, PxGlyph, PxGlyphFont, PxAnimationDefinition, PxScroll, PxScrollRangePoint, PxVec2, PxTransformValue | ● the shapes in Schema at a glance |
| Player API types | PxAnimatorApi<TRoot>, PxPlaybackApi<TRoot>, PxEngineCallbacks, PxPlatformAdapter | ● platform-neutral; the web fixes TRoot to Element. PxEngineCallbacks is what an engine takes — the lifecycle on top of PxDiagnosticsConfig |
| Component contract | PxAnimatorHandle, PxAnimatorCallbacks, PxControlProps, PxControlMode, resolveControlMode(props), controlModeTakesOverTrigger(mode), prepareDocumentForRender(doc) (▪), renderPxTree(node, factory) (▪), PxElementFactory (▪), PxElementSpec (▪) | ● what the React / Vue / React Native components share: the imperative handle, the callback set, the props that pick a mode and the one rule that picks it — the API at a glance. renderPxTree is THE renderer of every DOM player: it makes each decision about a document — tags, attribute names and values, sanitization, styles, text — and a player supplies only a PxElementFactory, “create an element from this finished PxElementSpec” (a DOM node, a React element, a Vue vnode). prepareDocumentForRender is the document it is given: materialized, then fresh ids |
| Engine rules | resolveTimelineEngine(engine), isNativeForced(engine), mayUseNativeScrollTimeline(engine) | ○ how a timeline.engine resolves to an engine / to the browser’s ScrollTimeline — the players’ own decision helpers |
| Trigger defaults | PX_TRIGGER_DEFAULTS, resolveTrigger(trigger) | ○ what a missing trigger field means (start ‘load’, offScreen ‘pause’, mouseOut ‘continue’, threshold 0.5, debounce 150 ms) — the one resolution every player uses |
| Time contract | seekCeilingMs, progressSpanMs, clampSeekMs, timeToProgress, progressToTimeMs, isValidPlaybackRate, PX_RATE_REJECTED, createRunClock + | ○ the one meaning of time, seeking and rate that every engine implements — see PxAnimatorApi |
| Diagnostics | createDiagnostics(config?, prefix?) (▪) → PxDiagnostics, PxDiagnosticCode, PxDiagnosticKind + PxDiagnostic, PxDiagnosticsConfig | ● the one channel every player reports through: onWarn / onError with a code saying what happened and a kind saying who can act, falling back to the console — see PxEngineCallbacks. The text is not shipped: PxDiagnosticCode is a number, described on the codes page |
| Enum values | PxTimelineEngineSetting, PxTimelineEngine, PxGradientType, PxUnits, PxGradientSpreadMethod, PxLoopRepeatAt, PxLoopDirection, PxStrokeTrimSubPaths, PxCloneWithout (clone.without → 'translate'), PxMaskType, PxPathOverflow, PxLengthAdjust, PxTextPathMethod, PxTextPathSpacing, PxFillMode, PxPlaybackDirection, PxTriggerStart, PxOffScreenAction, PxMouseOutAction, PxFinishAction, PxScrollKind, PxScrollAxis, PxScrollSource, PxScrollPhase, PxPinAlign, PxAlongPathMode, PX_TRANSFORM_PART_KEYS | ● named values instead of bare strings — each is a const namespace AND the type derived from it, so PxTriggerStart.click and start?: PxTriggerStart come from one import |
| Schema version | PX_WIRE_SCHEMA_VERSION, PX_WIRE_VERSION, PX_WIRE_BASELINE_VERSION, PX_WIRE_STEPS, PX_WIRE_VERSION_KEY, PxWireVersionRelation, parseWireVersion, formatWireVersion, readWireVersion, compareWireVersion, wireVersionAdvice, convertWireDocument, downgradeWireDocument, applyWireSteps, + PxWireVersion, PxWireVersionStep, PxWireConversionResult, PxWireStepKind, PxWireConversionOptions | ○ read, compare and convert a document’s animator.version — Versioning |
| Schema release | schemaFieldUniverse, diffFieldUniverse, planSchemaRelease, releaseLogProblems | ▪ the field inventory and bump rule behind scripts/schema-release.mjs |
| Diagnostics | diagnoseDocument(doc) (○) → ({ problems }), reportDocumentDiagnostics(doc, where), PX_UNKNOWN_KEY_ERROR | ▪ the load-time check every player runs; call validateDocument instead |
| Validation | isPxDocument, isValidPxDocument | ○ a cheap “is this a Pixodesk document” gate / the pass-fail form of validateDocument, NON-strict, with every message it can name |
| Document accessors | getAnimatorConfig, getChildren, getBindings, getDefinitions | ○ read a document without knowing its internals |
| Timeline shape | flattenAnimatorTimeline, nestAnimatorTimeline | ○ nested timeline object ⇄ the flat runtime view |
| Playback override | PxTimelinePatch, PxPlaybackOverride, PxAnimatorConfigPatch, PxAnimatorConfigMergeResult, PxTimelineShortcuts | ● the timeline override as every player takes it, and the companion types of the three merge functions above |
| Keyframe forms | keyframeValue, keyframeEasing, PxNormalizedKeyframe, PxNormalizedPropertyAnimation, PxAnyKeyframe | ▪ read a keyframe in either its wire or its runtime (t / v / e) form; is what normalizeBindings hands the engines — bare id + merged animation, from a binding or a node alike |
| Schema toolkit | px, schemaKeys, describeSchema — plus one schema value per wire type: PxAnimatedSvgDocumentSchema, PxNodeSchema, PxNodeBaseSchema, PxSvgNodeRootSchema, PxAnimatorConfigSchema, PxTimelineSchema, PxTimeTimelineSchema, PxTriggerSchema, PxElementAnimationSchema, PxPropertyAnimationSchema, PxKeyframeSchema, PxKeyframeValueSchema, PxAttrValueSchema, PxTransformPartsSchema, PxBezierPathSchema, PxLoopSchema, PxDefinitionsSchema, PxEffectsSchema, PxClipPathEffectSchema, PxCloneEffectSchema, PxRepeaterEffectSchema, PxRetimeEffectSchema, PxMaskedByEffectSchema, PxTransformByEffectSchema, PxTextEffectSchema, PxTextPathEffectSchema, PxStrokeTrimEffectSchema, PxFillGradientEffectSchema, PxGradientStopSchema, PxScrollSchema, PxScrollRangeSchema, PxScrollRangePointSchema, PxTransformValueSchema | ○ the validator the format is written in |
| Pipeline stages | normalizeBindings (○), calcAnimationValues (○), interpolateValue, materializeMotionPathInPropAnim, mergeStaticTransformIntoAnimDef | ▪ stages of materializeAllInTree; call the pipeline instead |
| Effect harness | diffInEffect | ▪ the editor’s “equal in effect” comparison |
| Text & paths | materializeGlyphText, layoutGlyphTextChars, createPathSampler, extendedPathForBrowser, materializeGlyphTextAlongPath | ▪ glyph-text and text-on-path materialization |
| Node props | toDomProps (○), sanitizeAttributeValue, PX_CSS_ONLY_STYLE_PROPS, PX_DISALLOWED_SVG_TAGS_LOWER | ▪ shared normalization and sanitization rules |
| Scroll math | isScrollTimeline, scrollViewProgress, scrollOffsetProgress, scrollPhaseInterval, scrollResolveAxis, scrollTotalDurationMs | ▪ scroll-driven playback internals |
| Maths & strings | cubicBezier, subdivideCubicBezier, bezierToSvgPath, splitEasing, reverseEasing, clamp, toRGBA, composeTransformParts, camelCaseToKebabWordIfNeeded, kebabToCamelCaseWord, PX_COLOR_ATTR_NAMES, PX_STYLE_ATTR_NAMES, PX_PCT_BASED_ATTR_NAMES, PX_TRANSFORM_FN_NAMES, deepClone, generateUniqueId, PX_DEFAULT_DURATION_MS, PX_DEFAULT_ITERATIONS, PX_LOOP_JUMP_SHIFT_MS | ▪ helpers shared with the editor |
| Schema toolkit types | PxSchema, PxSchemaDesc, PxInfer, PxValidationContext, PxRemoveIndex | ○ the types you build a schema with — see the toolkit above |
| Companion types | PxMaterializeAllOptions, PxCreateElement, PxGlyphCharBox, PxAnimatable | ▪ argument and result shapes of the functions above |
| Attribute names | PX_ANIM_ATTR_NAME, PX_ANIM_SRC_ATTR_NAME, PX_TEXT_CONTENT_ATTR | ▪ reserved keys — see Nodes |
Versioning
Section titled “Versioning”Two numbers, unrelated to each other:
| Number | Lives in | Moves when |
|---|---|---|
| Package version | every @pixodesk/svg-animator-* package | every release. All packages are released in lockstep; a player depends on the matching core version, so upgrading a player upgrades the core with it |
| Schema version | animator.version in the document | the file format changes — not on every release |
Schema version — animator.version
Section titled “Schema version — animator.version”A string "a.b.c":
| Part | Meaning |
|---|---|
a | format generation. Nothing converts across a change of a |
b | player schema revision. A player at a.b reads files of the same a and any b up to its own |
c | editor extension — covers meta.* only. The player ignores it |
This release reads schema 1.2 (PX_WIRE_SCHEMA_VERSION). The editor stamps every file it
saves ("1.2.1" today); the player never writes the stamp.
- Not a gate. A version gap on its own never refuses a file or warns. The player does not read the stamp when it plays a file — it checks the document against the schema it ships.
- Absent means unknown. An unstamped file is not assumed to be old, and nothing is converted on a guess.
readWireVersion(doc) reads the stamp from animator.version, or from meta.animator.version
where the animator block is carried under meta. parseWireVersion and formatWireVersion
convert between the "a.b.c" string and its parts, compareWireVersion compares two stamps, and
wireVersionAdvice words a gap the way advice below does.
Converting a document
Section titled “Converting a document”One step exists so far, 1.1 → 1.2: the trigger block became two axes, so a 1.1 file is
brought forward when it is read — startOn becomes start, outAction splits into offScreen
and mouseOut, and scrollIntoView becomes the default visibility gate. It is one-way: 1.2 can
say things 1.1 could not, such as “start on click and pause when scrolled away”.
import { convertWireDocument, downgradeWireDocument, parseWireVersion } from '@pixodesk/svg-animator-core';
// Up to this player's schema. Never refuses, never throws, never mutates `json`.const { doc, from, relation, applied, advice } = convertWireDocument(json);
// Down to an older schema — all or nothing.const target = parseWireVersion('1.1'); // refused here: the 1.2 step is one-wayconst down = target && downgradeWireDocument(json, target);if (down && !down.ok) console.warn(down.reason);relationis one ofunstamped·same·older·newer·otherGeneration(PxWireVersionRelation).adviceis set only when the version explains a gap, e.g. “This file is written for schema 1.5.0, this player reads 1.1.0. Update the player to open it fully.”- Up-conversion works on a copy. A step that fails returns the original document, never a half-converted one.
- Down-conversion refuses (
ok: false, with areasonnaming the steps that block it) unless every step on the way back can be undone. It also refuses an unstamped document. - Neither direction touches
meta.*— that part belongs to the editor.
From the command line, in a checkout of this repository. The script reads the built core, so run
pnpm --filter @pixodesk/svg-animator-core build first:
node scripts/upgrade-document.mjs animation.json # writes animation.upgraded.jsonnode scripts/upgrade-document.mjs animation.json --out new.jsonnode scripts/upgrade-document.mjs animation.json --in-placenode scripts/upgrade-document.mjs animation.json --to 1.1 # down-convertIt prints the file’s schema and each step applied, and writes nothing when there is nothing to convert. It takes JSON documents only: a pre-rendered SVG is an export, so re-export it from the editor.