Skip to content

Player 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

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.

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.

These 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 when:

  • 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 conversions runs, its output is always written in the simple layers only.

LayerWhere its data livesWhat it addsWho reads it
L0 — plain SVGthe element object itself: {type, ...attributes, children}the drawing — elements and their SVG attributes, exactly as in any SVG; values are plain stringsthe browser
L1 — typed valuesthe same SVG attributes as L0 — the layer changes their values, not their placevalues 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 holdsthe player
L2 — animated attributesnode.animate, one entry per animated attribute namekeyframes for any attribute; the element itself and its place in the tree are untouched — delete every animate key and a valid static SVG remainsthe player
L2 compiled — the low layerthe same node.animate entries, spelled { kind, keyframes }the explicit form the player lowers every entry to before it plays: values typed by kind, easings resolved, loops expanded, a motion path sampled (or kept for the frame loop) — nothing left to resolve. An entry is all authored or all compiled — Compiled animationthe player writes it; the editor reads it, never writes it
L3 — player effectsnode.effectseffects — short descriptions of masks, gradients, copies and other effects (see more), which the player expands into plain elements and attributes when the file loadsthe player
L4 — editor metanode.metaeverything only the editor needs — labels, shape presets, the sources of applied effects; the player ignores this key entirely — Editor meta and applied effectsthe editor
L5 — pre-rendered SVGunlike the layers above, this one is not a part of the JSON document — it is a separate .svg file the editor produces on exportthe 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 SVGdepends 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

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.

Everything the format is, in one small document — a ball that drops with an ease-in-out. The comments mark what is 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": { "outTime": 1000, "iterations": "infinite", "actionTriggers": { "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] }
] }
}
}
]
}

A few ideas cover most of the format:

  1. Every element is a JSON object — type holds the SVG tag name, every other key is an SVG attribute, and children is an array of the element’s child elements, nested the same way the tags nest in an SVG file.
  2. Any attribute can be animated by adding keyframes next to it — they go under the element’s animate key, filed by the attribute’s name. The static attribute itself stays where it is, so if you remove every animate, a valid static SVG remains.
  3. One place for playback settings — the root animator object holds everything about how the document plays: the content window, loops, what starts it.

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, playing on load and holding the final state. To have your own code start it instead, say "animator": { "timeline": { "actionTriggers": {} } } — an empty block lists no event.

start: 'load' is a file default, not a component default. Nothing plays until a prop or option names the driver (playUsingActionTriggers, play, showAt…). A file embedded with no code around it — the <div data-px-animation-src> tag, the CSS wrapper, a standalone SVG file — has no other possible driver, so there the embedding is that naming and the file plays as saved.

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 thisto type this part of the document
PxDocumentthe whole file
PxNodeany element in children; PxSvgNode is the root <svg>, which adds the document keys
PxAnimatorConfigthe animator block as a whole
PxTimelineanimator.timeline — what advances the playhead
PxActionTriggersanimator.timeline.actionTriggers — the requests a page event can make: start, stop, pause (and resume, derived unless the file names it); PxTriggerEvents is one of its lists (an event, or an array of them)
PxActionsanimator.timeline.actions — the moments whose outcome is configurable: one block each for start, stop, finish and outOfView — The two playback blocks
PxElementAnimationone element’s animate map; PxKeyframe is one entry of a property’s keyframes, and PxLoop that property’s loop
PxBindingone entry of animator.bindings, in a bind-by-id document
PxDefinitionsanimator.definitions; PxAnimationDefinition is one named animation in it, PxGlyphFont one embedded face and PxGlyph one letter outline in that face
PxEffectsa node’s effects bucket
PxTransformPartsa transform written in parts; PxTransformValue is one such value and PxVec2 an [x, y] pair
PxBezierPatha path outline as bezier segments
PxScrolla scroll-driven timeline; PxScrollRangePoint is one end of its range
PxTimelineEngineSetting · PxTimelineEngine · PxPlaybackDirectionthe timeline’s engine (as written: auto · native · js; as resolved: native · js) and iterationDirection. 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 PxTriggerEvent.click and start?: PxTriggerEvents come from one import
PxTriggerEvent · PxStartBehavior · PxStopBehavior · PxFinishBehavior · PxOutOfViewBehavior · PxShowWhenStoppedthe triggers’ events, the behavior of each actions block (start, stop, finish, outOfView), and the timeline’s showWhenStopped — Playback settings & triggers
PxScrollKind · PxScrollAxis · PxScrollSource · PxScrollPhase · PxPinAligna scroll timeline’s type, axis, source, a range point’s phase, and pin.align
PxAlongPathMode · PxLoopRepeatAt · PxLoopDirectiona property animation’s alongPathMode, and its loop.repeatAt / loop.direction
PxStrokeTrimSubPaths · PxCloneWithout · PxMaskType · PxUnits · PxGradientType · PxGradientSpreadMethod · PxPathOverflow · PxLengthAdjust · PxTextPathMethod · PxTextPathSpacingthe 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
// PxDocument
// 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' | string; // the root tag: 'svg', or an HTML tag ('div') around svg children
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 triggers). 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
inTime?: number; // the content WINDOW's start, ms (default 0); keyframe times are absolute content ms
outTime?: number; // …and its end. The editor always writes it; absent = the max keyframe time (else 1000)
playDuration?: number; // wall ms one pass of the window takes; default outTime − inTime (no remap)
delay?: number; // waited whenever a run starts from the beginning, ms (default 0) — never on a
// resume from a pause; 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)
iterationDirection?: 'normal' | 'reverse' | 'alternate' | 'alternateReverse'; // each iteration's direction (default 'normal')
actionTriggers?: { // the REQUESTS a page event can make. Absent block = { start: 'load' } (a FILE
// default); {} = nothing starts it until play(). The player's state picks the list an event is read from
start?: EVENTS; // acts when stopped (or finished) — a start per actions.start. load · hoverIn · click
stop?: EVENTS; // acts when playing or paused — a stop per actions.stop. hoverOut · click
pause?: EVENTS; // acts when playing — freezes the current frame. hoverIn · hoverOut · click
resume?: EVENTS; // acts when paused — carries on from there. DERIVED unless the file names it:
// each pause event's counterpart (hoverIn ↔ hoverOut, click → click) + the
// start events that do not also stop; a named list is honoured as written and
// the player notes where it differs (PX1111). hoverIn · hoverOut · click
}; // EVENTS = one event or an array of them: 'load' | 'hoverIn' | 'hoverOut' | 'click' — each list
// names only the events listed beside it; any other is dropped by the document repair.
// The two click toggles are spelt with the lists: start + stop on 'click', start + pause on 'click'
actions?: { // the MOMENTS whose outcome is configurable — written once here, honoured by the
// triggers, a component's `play` prop and the api alike. `behavior` is a PRESET; a slot
// that says only its preset may be the word itself: `stop: 'rewind'` ≡ `{ behavior: 'rewind' }`
start?: 'continue' | 'restart' | 'jumpToFinished' | { behavior?: 'continue' | 'restart' | 'jumpToFinished' };
// default 'continue': plays on from where it is (at the start or the end, a
// fresh run); 'restart' from the beginning always; 'jumpToFinished' = the end pose, no motion
stop?: 'reset' | 'rewind' | { behavior?: 'reset' | 'rewind'; completeIteration?: boolean };
// default 'reset': jumps back to the state as before it started; 'rewind' plays
// back to it. completeIteration: now (default false), or after the current iteration
finish?: 'hold' | 'reset' | 'rewind' | { behavior?: 'hold' | 'reset' | 'rewind' }; // the run ends by itself; default 'hold' keeps the last frame
outOfView?: 'pause' | 'continue' | 'reset' | { behavior?: 'pause' | 'continue' | 'reset' };
// the visibility GATE — a condition on every driver, not an event: while none of it
// is on screen (default 'pause'; 'continue' = keep running; 'reset' jumps back, so the
// next entry replays it). Seen again (`wake`), it carries on what was meant to run
};
showWhenStopped?: 'static' | 'firstFrame'; // what shows while STOPPED — before the first start, after a reset or
// where a rewind lands: the SVG as authored (default 'static'), or the first
// frame's values — hand-authored files only
inViewThreshold?: number; // how much must be on screen before it may run: 0 = any part, 1 = all of it; default 0.5
inViewDebounce?: 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 triggers/actions/delay slots exist here
inTime?: number; // the content WINDOW's start, ms (default 0)
outTime?: number; // …and its end — the keyframe span the scroll range maps onto
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' | 'entryCrossing' | 'exitCrossing'
};
// 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; // the units of `ascent`, `width` and `pathData`; default 1000
glyphs: Record<string, { width: number; pathData: string }>; // keyed by the character
}>;
};
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 element
interface 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]: unknown; // 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?: unknown; // 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 loads
interface 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 | Animated /*inline SVG d, static or keyframed {pathData}*/, 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 animation
interface 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 window 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
};
}

actionTriggers keys are the requests a page event can make — start, stop, pause (resume is derived unless the file names it). actions keys are the moments whose outcome is configurable — start, stop, finish, outOfView. start and stop are in both because each is both a request and a moment; pause has one behaviour (freeze), so there is no actions.pause; finish and outOfView are not page events, so they have no list. The lists, the state rule and the presets are on Playback settings & triggers.

NameWhy
animatorthe one block for everything about playing — the timeline, and the lookup tables definitions / bindings; the drawing around it stays plain SVG
timelineWAAPI’s word for what advances the playhead (DocumentTimeline / ScrollTimeline / ViewTimeline): type picks the kind, and timing lives inside it
actionTriggersnot triggers: the word action links it to actions — a key here is a request a page event can make, the same key there the moment whose outcome is configurable
actionskeyed by moments — start, stop, finish, outOfView: “what happens at this moment” is the one reading that fits all four
behavior”stop behavior: rewind” says what kind of thing the value is; do (1.11) and transition (1.10, read as CSS) did not
outOfViewnames the condition in the words inViewThreshold / inViewDebounce already use; suspend (until 1.12) asked “suspended by what?”
showWhenStoppedit governs the whole stopped state — after a reset or a rewind too, not only before the first start — with the show… verb of showAtProgress / showAtTime; beforeStart (until 1.14) read as “the first start only”
inTime / outTimethe editor’s in and out points — positions in the CONTENT, not in the run (a run with iterations: 3 has one outTime and three passes); not startTime / endTime
playDurationthe one wall-clock number on the timeline; a bare duration would not say which time axis it is on (content ms: inTime / outTime; wall ms of one pass: this; ms into the run: delay, showAtTime, getCurrentTime())
iterations / iterationDirectionWAAPI’s words and values; iteration in front because the direction is per iteration — a scroll timeline has an axis direction of its own
engine: 'native'platform-neutral: the browser’s Web Animations API on the web, the platform’s own driver elsewhere — React Native accepts the key and ignores it. engine, not mode: it selects HOW attributes get updated, not what you see
loadnot ready: the gate is a condition on every driver, so load is the event as it is; ready would read as the gate’s answer
targetan #id, never a CSS selector — WAAPI’s word (KeyframeEffect.target); players mint fresh ids and remap an #id, a raw selector could not be remapped
versionnot schemaVersion: inside animator the word already says whose version it is, and the three-part number is this format’s own, not semver

The root may be an HTML element instead of svg: a small page — headings, text, layout — with one or more svg drawings inside it. Elements are HTML outside <svg> and SVG inside it: the player creates them in the matching namespace, a node without type is a div outside and a g inside. The animator, bindings and animate work the same; animate HTML elements through properties that apply to them (CSS ones in style). Tags that load or run other content (script, iframe, object, embed, link, meta, base, frame) are never rendered.

{
"type": "div",
"style": { "textAlign": "center" },
"animator": { "timeline": { "outTime": 1000 } },
"children": [
{ "type": "h1", "textContent": "Hello, world!" },
{ "type": "svg", "viewBox": "0 0 400 400", "width": 300, "height": 300,
"children": [{ "type": "circle", "cx": 200, "cy": 60, "r": 40,
"animate": { "cy": { "keyframes": [{ "time": 0, "value": 60 }, { "time": 1000, "value": 320 }] } } }] }
]
}
FieldTypeMeaning
typestring — usually "svg"required — the root element’s tag. "svg" for a drawing; an HTML tag such as "div" for a page that wraps one or more svg children (see HTML around SVG)
idstringthe 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)
viewBoxstringthe drawing’s coordinate space, exactly as in SVG — e.g. "0 0 700 380" means “the drawing spans 700 × 380 units”
width · heightnumber or stringhow 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%"
animatorobjectthe playback settings — the window, loops, triggers, etc — see Playback settings and Definitions
childrenarray of element objectsthe nested SVG children tree
animate · effects · style · meta · textContent · domTypeas on any nodethe root <svg> is a node too — every node key applies to it
any SVG attributestring or numberany 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 } ] } } }
FieldMeaning
typethe 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, …
idDOM 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
childrennested nodes
animatethis node’s animations — below
effectsthis node’s effects — Player effects
styleinline style declarations — an object of camelCase CSS property → value, like React’s style prop (whiteSpace, pointerEvents); an explicit attribute on the node wins
textContenttext content of <text> / <tspan>
domTypethe 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
metaeditor-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 keyan 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).

Values the format defines are camelCase too — alternateReverse, entryCrossing, offsetPath, userSpaceOnUse — even the ones taken from CSS or Web Animations: the players spell them the browser’s way only where they hand them over. Attribute and style values are not the format’s own words; they are written as the DOM takes them (fill: 'none', whiteSpace: 'pre-wrap').

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).

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 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 these forms, consistently across the format:

{ fill: '#3b82f6' } // 1. primitive — static
{ transform: { value: { translate: [10, 10] } } }// 2. {value} — structured static
{ opacity: { keyframes: [ … ] } } // 3. {keyframes} — animated
FieldTypeMeaning
keyframesarraythe timeline
valuesame as a keyframe’s value (Keyframe values)optional static baseline (rarely needed — the static attribute on the node is the baseline)
looptrue or objectthis one property repeats on its own, independent of the whole document’s iterations — Per-property loops
autoOrientbooleantranslate animations with tangents: rotate the element to face the path — Motion along a path
alongPathModesampled (default) · offsetPathtransform only: how a motion path is rendered — as pre-sampled keyframes, or as CSS offset-path
FieldTypeMeaning
timemsoffset from the start of the document timeline
valuedepends on the property — Keyframe valuesthe property’s value at this time
easing[x1, y1, x2, y2] or a namehow 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 keyframe key has exactly one spelling — there are no short aliases such as t / v.

Property kindvalueExample
a single number (opacity, r, strokeWidth, rotate, …)number0.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 transforman 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 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.

A property can repeat part of its own keyframes to fill the timeline’s window (outTime), 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" } }
FieldMeaning
segmentCounthow 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
repeatAtwhich 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.

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] } }
] } }
PartTypeNotes
translate[x, y]how far to move along x and y — plain numbers in the drawing’s coordinates (the viewBox space)
rotatenumberdegrees
skewnumberskewX 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.

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, so transform: {rotate: 45} + keyframes animating only translate slides the element while it stays rotated.

Deprecated: the per-key form (animate: { translate: {…} }, one channel per part) is still READ — files migrated from older versions carry it — but it is no longer the way to write documents, and the player warns about it (PX1107): two per-key channels on one node are last-write-wins, each writing the whole transform. Spell the pose as animate.transform record keyframes; parts that need their own timing belong in the transformBy effect.

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).

Before it plays a document, the player LOWERS every animate entry to its compiled form, and the result stays a valid document of this format — the same keyframe keys, the values typed by a kind, and nothing left to resolve:

"fill": { "keyframes": [ { "time": 0, "value": "#ff0000", "easing": "smooth" }, { "time": 400, "value": "#0000ff" } ], "loop": true } // authored
"fill": { "kind": "color", "keyframes": [ { "time": 0, "value": [1, 0, 0, 1], "easing": [0.42, 0, 0.58, 1] }, … ] } // compiled
  • All authored or all compiled. An entry with a kind is compiled through and through; an entry without one is authored. A compiled kind with authored values ("kind": "color" holding "#ff0000") is not valid.
  • Explicit. time is required; easing is a resolved curve, never a name; there is no loop (it is expanded into keyframes over the window). Times are content ms, like the authored ones.
  • Per engine. For the native engine a motion path is sampled into plain transform keyframes; for the frame loop it keeps its tangents and autoOrient, and is evaluated each frame.
  • Who writes it. The player, in prepareDocument’s plain stage. The editor reads a compiled entry back as its authored form and never writes one.
kindvalueExtra
numbera numberunit — the unit every authored value carried ("20%" → 20 with "unit": "%")
percenta 0..1 number, written as a percentage (offset-distance)
rotate, skewa number (degrees)
color[r, g, b, a], every channel 0..1 — always four
path{ "paths": [ { "v", "i", "o", "c" } ] } — the parsed bezier paths of d
transforma parts recordkeyframe tangentIn / tangentOut and autoOrient — a motion path the frame loop evaluates
numberList, translate, scalean array of numbers
discretea string — switches at the half, never blends (url(#a), none, …)

isCompiledAnimation(entry) tells the two forms apart; PxAnyPropertyAnimationSchema validates an entry of either form (PxAnyPropertyAnimation), PxCompiledAnimationSchema the compiled one alone (PxCompiledAnimation, its keyframes PxCompiledKeyframe). A compiled colour is a PxRgba (PxRgbaSchema), a resolved easing a PxCubicBezier (PxCubicBezierSchema). Schema 1.6 added this layer.

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" } } } }
}
FieldWhat it holdsHow an element uses it
easingsnamed easing curvesa keyframe writes the name instead of the curve: "easing": "smooth"
animationsnamed animationsa node writes the name instead of the keyframes: "animate": "fadeIn" (a bind-by-id document names them in its bindings)
fontsembedded 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 outlinesa <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

When the editor saves a pre-rendered SVG + JS animation file, it puts these 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 { createSvgAnimationPlayer } from '@pixodesk/svg-animator-web';
createSvgAnimationPlayer({ container: '#box', doc: { // an empty <div id="box"> on the page
type: 'svg', id: '_px_root',
animator: {
timeline: { outTime: 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.

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:

ValueUnit
time (time, timeline.inTime / outTime / playDuration, timeline.delay, retime.start)milliseconds
lengths, coordinates, fontSize in pxplain numbers in the drawing’s coordinates (the viewBox space) — no unit is written
rotate, skew, anglesdegrees
opacity, trim range / offset, stop offset, inViewThresholda share of the whole, from 0 (none) to 1 (all)
every scalea multiplier: 1 = unchanged, 2 = double, 0.5 = half
retime.stretcha multiplier of duration: 2 = twice as long (half speed), 0.5 = half as long (double speed)
frameRateframes per second
easingcubic-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.warn listing the problems (the first few, then a count), e.g. root.animator.timeline.duratoin: unexpected extra key. The strict parts — the animator block, every ANIMATE, every effect — report any unknown key, so a keyframe where keyframes was meant is caught.
  • Effects are also checked one by one (validateNodeEffects); each problem is a console.warn prefixed 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. Its errors are the same problem strings, empty 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.

Complete documents, one idea each.

Self-contained, clock timeline:

{
"type": "svg",
"viewBox": "0 0 400 400",
"animator": {
"timeline": { "outTime": 1000, "iterations": "infinite",
"actionTriggers": { "start": "load" } }
},
"children": [
{ "type": "ellipse", "fill": "#007fff85", "rx": 64, "ry": 64,
"animate": { "transform": { "keyframes": [
{ "time": 0, "value": { "translate": [139, 163] } },
{ "time": 1000, "value": { "translate": [139, 310] } }
] } } }
]
}

Named definitions + unified transform:

{
"type": "svg",
"viewBox": "0 0 600 400",
"animator": {
"timeline": { "outTime": 2000, "iterationDirection": "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": { "outTime": 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": { "outTime": 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", "outTime": 3000,
"range": { "start": { "phase": "entry", "fraction": 0 },
"end": { "phase": "exit", "fraction": 1 } } }
},
"children": [
{ "type": "rect", "width": 80, "height": 80, "fill": "#10b981",
"animate": { "transform": { "keyframes": [
{ "time": 0, "value": { "translate": [0, 0] } }, { "time": 3000, "value": { "translate": [320, 0] } } ] } } }
]
}

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’s animate: "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:

AppliedEffectWhat it does
1textglyph-outline text rendering
2textPathtext along a path
3fillGradient / strokeGradientgradient paint with animatable stops and geometry
4strokeTrimreveal or hide a stroke progressively along its path
5repeaterN copies, each stepped by a delta
6maskedBymask by another element
7clipPathclip to a (possibly animated) path
8transformByper-part transforms with independent timing
9clone<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.

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.

FieldTypeMeaning
useGlyphsbooleanrender 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": { "outTime": 1000 },
"definitions": {
// The outlines the text is drawn from — one entry per letter used
// (paths shortened here; the editor writes the real ones)
"fonts": {
"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.

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 either 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:

FieldTypeMeaningApplies to
pathDatapath string | Animated<path string>the path geometry (inline — no separate element needed). Animated: each keyframe’s value is { "pathData": "M…" }, every keyframe with the same points, like an animated dbrowser text, glyphs
startOffsetnumber | Animated<number>where the text starts along the path (SVG spec)browser text, glyphs
textLengthnumber | Animated<number>stretch / squeeze the text to this length (SVG spec)browser text, glyphs
lengthAdjustspacing (default) · spacingAndGlyphshow textLength is reached: by changing the space between glyphs only, or by stretching the glyphs too (SVG spec)browser text only
methodalign (default) · stretcheach glyph is rotated to sit on the path, or the glyphs themselves are bent to follow its curve (SVG spec)browser text only
spacingauto · 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
pathOverflowextend (default) · clipglyphs past the end of an open path continue along the tangent, or disappear. Browser text falls back to clip while pathData animatesbrowser text, glyphs

A moving path (animated pathData) works everywhere for glyph text — each glyph is re-placed on the path as it bends. Browser text follows it only with the frame loop (engine: 'auto' switches for you); CSS exports keep the first keyframe’s path.

{ "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": {
"pathData": "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" } ] }

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.

FieldTypeMeaning
typelinear · radiala 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)
stopsarray 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
gradientUnitsobjectBoundingBox (default) · userSpaceOnUsewhich 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)
spreadMethodpad (default) · reflect · repeatwhat to paint beyond the last stop: extend the end color, mirror the gradient back, or start it over (SVG spec)
gradientTransformstringstatic 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.

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).

FieldTypeMeaning
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
offsetnumber | Animated<number>slides that visible part along the path, as a share of its length: 0.25 moves it a quarter of the way
subPathsseparate (default) · combinedwhat 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.

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.

FieldTypeMeaning
copiesnumberstatic — the count cannot animate
translate[x, y] | Animated<[x, y]>per-copy step
rotatenumber | Animated<number>per-copy degrees
skewnumber | 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 } ] } } } }

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.

FieldTypeMeaning
source"#id"the element that becomes the mask
maskTypealpha · luminance (default)how the source’s pixels become mask values (CSS spec)
maskUnits · maskContentUnitsuserSpaceOnUse · objectBoundingBoxSVG’s mask coordinate systems — omitted, maskUnits is objectBoundingBox and maskContentUnits is userSpaceOnUse, as in SVG (SVG spec, maskContentUnits)
x · y · width · heightnumbersthe 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" } } }

Clips the element by the given path — which can be animated. It simply creates a usual <clipPath> for this element and links the two.

FieldTypeMeaning
pathDatapath 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" } }
] } } }

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.

FieldTypeMeaning
translate[x, y] | Animated<[x, y]>how far to move along x and y — plain numbers in the drawing’s coordinates
rotatenumber | Animated<number>degrees
skewnumber | 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 } ] }
} }

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.

FieldTypeMeaning
source"#id"the source element / symbol (the <use> also keeps its normal href)
withouttranslate (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.startmsshift the source’s internal timeline
retime.stretcha multiplier of duration2 = 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.

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.

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 } } } } }
FieldWhich elements carry itWhat it holds
labelany elementthe display name shown in the editor’s element tree
appliedEffectsa plain nodethis node’s own effects, already applied — Applied effects
effectsHostthe host of expanded parts, pre-rendered SVG onlysome 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
partOfevery element derived by that expansion, pre-rendered SVG onlythe 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
runtimeroot <svg> onlyhow the animation code was generated: { useCssAnimation, useJsTriggers, externalJs, unoptimizedJs } — the export-format choices, not the animation
animatorroot <svg>, pre-rendered SVG onlythe playback settings; in JSON they are the top-level animator instead (read more)
timeline<symbol> only{ duration } — the symbol’s own animation length, ms
lineSpacingtext line <tspan>s from the second line onthe Auto line-height multiplier the materialized y was computed from
animateany element, pre-rendered SVG onlythe node’s keyframes, so a CSS export can be re-opened; in JSON this is the node’s own animate

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 either of these places, and the place says what it means:

MeaningWhich effects can appear
node.effectsapply these. The player reads this when the document loads and applies the effects — Player effectsthe player effects: text, textPath, fillGradient, strokeGradient, strokeTrim, repeater, maskedBy, clipPath, transformBy, clone
node.meta.appliedEffectsthese 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 resultthe 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 into node.d (or node.animate.d when it animates); shape keeps 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 (rounded corners) was applied to it.
  • text — widened with fontSource and content, the payload that lets glyph-rendered text be edited as text again.
  • clone — widened with the width / height of a materialized <use>.
  • combinedPath: true — an identity effect the writer adds beside strokeTrim when 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.

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, materializing them for a renderer of your own, or computing values at a given time without rendering anything.

Terminal window
npm install @pixodesk/svg-animator-core

ONE call answers everything (review §15): validateDocument(doc) checks the whole document — the wire schema, every node’s effects bucket, the glyph-font and easing references, the version stamp — and returns a PxValidationResult:

// Check a generated document before it ships — a build step, a test, a tool taking user files
import { validateDocument, isPxDocument } from '@pixodesk/svg-animator-core'; // also exported by the web player
import { readFile } from 'node:fs/promises';
const json = JSON.parse(await readFile('animation.json', 'utf8')); // Node — or fetch() in a browser
if (!isPxDocument(json)) throw new Error('not a Pixodesk document'); // the cheap shallow gate
const { valid, errors, findings } = validateDocument(json);
if (!valid) throw new Error(errors.join('\n')); // "path: what is wrong", one line per problem
// Code that must BRANCH on what went wrong reads `findings`, never the text — the structured
// twin of `errors`: one PxValidationFinding per line, with a PxValidationFindingKind and the
// path as segments: { kind, path: ['children', '[0]', …], reason, message, expected?, memberFindings? }
const writtenByNewerVersion = findings.some(f => f.kind === PxValidationFindingKind.unknownKey);

The kinds are unknownKey (a key the schema does not declare — only reported under strict), typeMismatch, invalidValue (a literal / enum miss, or an unparseable version stamp), missingValue, missingReference (a glyph font face or easing name with no definition) and unionMismatch. A failed union is ONE unionMismatch finding: the best-matching member’s own diagnosis sits nested in its memberFindings, so an extra key met while trying a union member describes a malformed value and never counts as the document’s own unknown key.

ModeQuestion it answersUnknown keys
validateDocument(doc) — strict, the defaultis this document exactly well-formed? — before you shipreported as errors
validateDocument(doc, { strict: false })is this document usable? — what a reader acceptstolerated (forward-compatible: an unknown key usually means a newer writer)

These two are the whole public checking surface for a DOCUMENT — validateDocument and the isPxDocument guard. The narrower checkers it is built from (per-part walkers) live on @pixodesk/svg-animator-core/internal for the players and the editor.

Every schema can also turn an unchecked value into a typed one — the way to read any part of a document without guessing its type:

import { PxKeyframeSchema, PxSchemaCastError } from '@pixodesk/svg-animator-core';
PxKeyframeSchema.is(value); // a type guard: true narrows `value` to a keyframe
PxKeyframeSchema.cast(value); // the keyframe, or throws PxSchemaCastError naming each problem's path
PxKeyframeSchema.castOrNull(value); // the keyframe, or null
const { value: kf, problems } = PxKeyframeSchema.castOrFix(value); // a repaired copy + what was repaired
  • castOrFix checks in one visit and builds a NEW value: declared fields are checked and repaired (to the schema’s default, or dropped), undeclared keys are kept as they are — it is as forgiving as a reader — and problems lists every repair (PxCastResult). The input is never modified.
  • Checked values are stamped. Every object and array castOrFix builds remembers which schemas it satisfies, so is / cast / castOrNull on it answer at once instead of walking it again. The stamp is invisible to JSON.stringify and Object.keys; a copy, a clone or a saved file loses it and is simply checked again. Passing a stamped value to castOrFix returns it as it is, and an edited copy only re-checks what changed.
  • prepareDocument runs castOrFix over the whole document in its checked stage (below), so every part of a prepared document casts for free.
  • To walk a schema itself, describeSchema(schema) returns its structure (PxSchemaDesc: an object’s fields, an array’s item, a union’s members, …). Each part is a PxAnySchema — a schema of some value — so a walker stays typed all the way down.

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. getAnimatorConfig returns the block as the players read it, a PxAnimatorRuntimeConfig: timeline folded into top-level duration, actionTriggers, actions, iterations and the rest. That flat shape is not something to write — a file’s animator is a PxAnimatorConfig, and nestAnimatorTimeline turns the flat shape back into one.

prepareDocument(doc, options?) is the one call that lowers a document as far as you need — always in this order — and remembers how far it got:

Stage (PxDocStage)What is done
currentmigrated to the schema this build reads — Versioning
checkedchecked and repaired against the schema in one visit, every part stamped — see Casting a value with its schema; onProblems receives what was repaired
resolvedthe animator config’s runtime view computed once and attached: getAnimatorConfig on the result is a plain read
plain (the default)materialized for an engine — Materializing a document, below — what the Web Animations API or the frame loop consumes directly
  • The input is never changed. The result is a new document stamped with its stage (documentStage(doc)); the stamp is invisible to JSON.stringify and Object.keys, and a saved or cloned copy loses it.
  • Prepared once, trusted after. Preparing a document that already reached the stage (for the same engine) returns it unchanged — passing a prepared document on and preparing it again “just in case” costs nothing. The result’s type, PxPreparedDocument, says it was prepared.
  • Edits start over. The stamp is trusted only while the document keeps the animator config it was prepared with; an edited copy is a new object and is prepared afresh.
  • freshIds: true mints new ids on the result — needed before putting the same animation on a page twice.
import { prepareDocument, documentStage, getAnimatorConfig } from '@pixodesk/svg-animator-core';
import doc from './bouncing-ball.json';
const ready = prepareDocument(doc, { freshIds: true }); // current → checked → resolved → plain
documentStage(ready); // 'plain'
getAnimatorConfig(ready)?.duration; // a property read — nothing re-derived
prepareDocument(ready) === ready; // true: trusted, nothing redone

materializeAllInTree(doc, engine) — the plain step of prepareDocument — expands every indirection into plain nodes and plain keyframes, a tree any renderer can walk:

  1. Effects — every node.effects becomes real nodes, wrappers and defs.
  2. Animations — every animate entry is lowered to its compiled form: values typed by kind, easings resolved, each loop expanded into explicit keyframes, the static transform composed under the animated one, and a motion path (tangented transform keyframes, autoOrient) sampled into plain { translate, rotate } keyframes — or, for the frame loop, kept to be evaluated each frame.
  3. Animated <use> — replaced by a <g> with a deep clone and fresh ids.
  4. 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 transformBy wrapper, 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 into transform, 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.

Step 3 runs when engine is native, as does the sampling in step 2. 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, PxTimelineEngine } from '@pixodesk/svg-animator-core';
import { calcAnimationValues, normalizeBindings } from '@pixodesk/svg-animator-core/engine';
import doc from './bouncing-ball.json';
const plain = generateNewIds(materializeAllInTree(doc, PxTimelineEngine.native));
// values at any time, no renderer involved
for (const binding of normalizeBindings(plain, PxTimelineEngine.js) ?? []) {
const values = calcAnimationValues(binding.channels, 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. The two sampling calls come from @pixodesk/svg-animator-core/engine, the entry for player authors.

normalizeBindings hands back each element as a PxNormalizedBinding: its id and its channels (PxRuntimeAnimation) — one compiled animation (PxCompiledAnimation) per animated property, shifted from content ms into window ms. An authored entry (an unprepared document, the pre-rendered builds) is compiled there. What a property’s values ARE is decided once and carried as its kind (PxValueKind): a colour as RGBA numbers (PxRgba), d as bezier paths (PxPathValue), transform as a parts record, a number with its unit ("20%" → "80%" blends and stays a percentage); a value that fits no blendable kind (url(#a) → url(#b)) switches at the halfway point instead of blending. Easings are resolved (PxCubicBezier); each keyframe is a PxCompiledKeyframe, typed by its kind.

Implement PxPlatformAdapter and hand it to createAdapterSvgAnimationPlayer — both from @pixodesk/svg-animator-core/engine, the entry for player authors; the engine handles timing, delay, iterations and their direction, the play actions, playback rate and the lifecycle callbacks, then calls you with plain attribute writes:

import { materializeAllInTree, generateNewIds, PxTimelineEngine } from '@pixodesk/svg-animator-core';
import { createAdapterSvgAnimationPlayer, type PxPlatformAdapter } from '@pixodesk/svg-animator-core/engine';
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 = createAdapterSvgAnimationPlayer(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.

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.

// ○ The one lowering pipeline — current → resolved → plain — stamped on the result; a document
// that already reached the stage comes back unchanged (see Preparing a document).
function prepareDocument(doc: PxDocument, options?: PxPrepareOptions): PxPreparedDocument;
function documentStage(doc: PxDocument): PxDocStage | undefined; // undefined = a raw document
interface PxPrepareOptions {
stage?: PxDocStage; // 'current' | 'checked' | 'resolved' | 'plain' (the default)
engine?: PxTimelineEngine; // what `plain` materializes for; default: the document's own
freshIds?: boolean; // mint new ids on the result
onProblems?: (problems: ReadonlyArray<PxValidationFinding>) => void; // what `checked` repaired
}
// ○ Run the whole materialization pipeline: effects → loops → motion paths →
// <use> instances → rest poses, always in this order. This is exactly what the player
// runs internally, so a document materialized 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: PxDocument,
engine: 'native' | 'js',
options?: { motionPath?: MotionPathMaterializationOptions },
): PxDocument;
// ○ One stage of it: `node.effects` → plain renderable nodes (+ generated defs).
function materializeNodeEffects(root: PxNode): ApplyResult;
// ● The cheap shallow gate — "is this a Pixodesk document at all?" (a type guard). With
// `validateDocument` below, the whole public checking surface (review §15).
function isPxDocument(doc: unknown): boolean; // a type guard: true narrows to PxDocument
// ● THE whole-document check (review §15 — the ONE public answer): the wire schema (undeclared
// keys included under strict), every `effects` bucket, the glyph-font and easing references
// and the version stamp. Answers `{ valid, errors, findings }`: problem lines
// (`path: what is wrong`) and their structured twins; 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 }): PxValidationResult;
// `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. The narrower checkers live on
// the `/internal` entry.
// ── Declarative playback plumbing (review §10) — what the React / Vue / React Native
// components are built FROM, public for anyone building a component of their own. ○
// ONE executor performs every built-in action (from `/engine`): `createPlayExecutor(api, cfg, raf)`
// builds a `PxPlayExecutor` over any `PxPlayTarget` from a `PxPlayExecutorConfig` — `start()` per
// `actions.start`, `stop()` per `actions.stop` (transition, completeIteration), `onNaturalFinish()`
// per `actions.finish`; `executorConfigOf(view)` reads that config from the runtime view
// (a `PxApiExtrasConfig`); `ensurePlayExecutor(api, cfg, raf)` reuses the one an engine attached;
// `composeApiExtras(api, cfg, raf)` completes an engine's api (a `PxApiExtrasTarget`) with the
// COMPOSED members (`PxApiExtras`) — `play()`, `stop()`, `reset()`, `playOnce()`, `getDuration()`.
// `PxEngineAnimationPlayerApi` is what an engine constructs — the full api minus those.
//
// ○ The props side (from `/engine`): `resolvePlaybackWorld(iterations)` says what `play` drives — a
// POSE on a one-shot document (`PxPlaybackWorld.oneShot`) or a RUN on a looping one;
// `resolveDeclarativePlayback(args: PxDeclarativeArgs)` turns a props change
// (`PxDeclarativeProps`) into ordered `PxDeclarativeAction`s plus usage warnings;
// `applyDeclarativeAction(api: PxDeclarativeTarget, action, totalMs)` executes one on the api.
//
// ○ The document side (from `/engine`): `applyPlaybackOverride(doc, { timelineOptions, actions, playUsingActionTriggers })`
// folds a mount's override into the file's — the timing and the actions merged, the triggers SET as a
// whole when `playUsingActionTriggers` is an object (`withActionTriggers(doc, actionTriggers)`); `withoutTriggerDriver(doc)` gives
// the file an EMPTY triggers block when nothing names a driver; `triggerActionFor(triggers, state, event)`
// is the state rule every wiring reads an event through; `fillModeOf(showWhenStopped, finishBehavior)`
// is the `PxFillMode` (`animation-fill-mode`) those two settings spell, for CSS and the WAAPI engine — see
// [Playback settings & triggers](../library/playback-and-triggers.md#for-player-authors--the-same-rules-exported).
// ● 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: PxDocument,
patch: PxAnimatorConfigPatch,
): { doc: PxDocument; warnings: Array<string> };
// ○ The same merge one level down, on the config object itself.
function mergeAnimatorConfig(
base: PxAnimatorConfig | undefined,
patch: PxAnimatorConfigPatch,
): PxAnimatorConfigMergeResult;
// ○ The schema version this build reads, parsed — see Versioning, below.
// `formatWireVersion(PX_WIRE_VERSION)` gives the "a.b.c" string back.
const PX_WIRE_VERSION: PxWireVersion;
function readWireVersion(doc: unknown): PxWireVersion | undefined; // animator.version (or meta.animator.version)
function convertWireDocument(doc: unknown): PxWireConversionResult; // up to this schema; never refuses, never mutates
function downgradeWireDocument(doc: unknown, target: PxWireVersion): PxWireDowngradeResult; // all or nothing
// ○ Deep-clone a document with fresh ids and internal references rewritten — for a renderer
// of your own; every player already does this for each render.
function generateNewIds(doc: PxDocument): PxDocument;
// ○ (from `/engine`) Playback on a non-DOM target: implement the adapter, get the frame-loop engine.
function createAdapterSvgAnimationPlayer(
doc: PxDocument,
adapter: PxPlatformAdapter,
callbacks?: PxEngineCallbacks,
): PxAnimationPlayerApi;
interface PxPlatformAdapter {
isConnected(): boolean; // is the target still mounted
setAttribute(id: string, attrName: string, value: string): void;
}
// ● (from `/engine`) Decides who controls a component, from the props you gave it. React, Vue and
// React Native all call this, so they always decide the same way. The first match
// wins:
// 1. `showAtProgress` or `showAtTime` set → you control the position
// 2. `play` or `pause` set → you control playing and pausing
// 3. `playUsingActionTriggers` set → the document's own triggers, or the ones you passed
// 4. nothing set → static: the first frame, nothing plays
// If you set props from more than one line, the result includes a ready-made
// warning to show. `playerRef` never changes the result: it is a handle for calling
// methods, not an instruction.
function resolveControlMode(props: PxControlProps): PxResolvedControlMode;
// ● (from `/engine`) True when that mode has to take the document's own triggers over — every mode
// except `playUsingActionTriggers`, INCLUDING `static`: a component given no control props at
// all renders the first frame and waits, so it gives the document an EMPTY triggers block
// (`withoutTriggerDriver`).
function controlModeTakesOverTrigger(mode: PxControlMode): boolean;

Names marked /engine come from @pixodesk/svg-animator-core/engine: the engine wiring the players share — the time contract, the component control rules, the runtime form and the platform adapter. You need it only to build a player; everything else is on the main entry.

GroupIdentifiersAudience · what it is for
Wire typesPxDocument, PxDocumentInput, toPxDocument(doc) (○), PxNode, PxSvgNode, PxAnimatorConfig, PxTimeline, PxActionTriggers, PxTriggerEvents, PxActions, 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 typesPxAnimationPlayerApi<TRoot>, PxEngineAnimationPlayerApi<TRoot> (○), PxEngineCallbacks, PxPlatformAdapter● platform-neutral; the web fixes TRoot to Element. /engine: PxEngineAnimationPlayerApi, PxPlatformAdapter. PxEngineCallbacks is what an engine takes — the lifecycle on top of PxDiagnosticsConfig
Component contractPxAnimationPlayerHandle, PxAnimationPlayerCallbacks, PxControlProps, PxControlMode, resolveControlMode(props), controlModeTakesOverTrigger(mode), resolveDeclarativePlayback(args) (○), PxDeclarativeAction (○), PxDeclarativeProps (○), PxDeclarativeArgs (○), PxDeclarativeTarget (○), applyDeclarativeAction(api, action, totalMs) (○), resolvePlaybackWorld(iterations) (○), PxPlaybackWorld (○), prepareDocumentForRender(doc) (▪), renderPxTree(node, factory) (▪), PxElementFactory (▪), PxElementSpec (▪)● what the React / Vue / React Native components share: the imperative handle (play(), pause(), stop() …), the callback set, the props that pick a mode and the one rule that picks it — the API at a glance. /engine: PxControlProps, PxControlMode, resolveControlMode, controlModeTakesOverTrigger and the ○ names. 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 rulesresolveTimelineEngine(engine), isNativeForced(engine), mayUseNativeScrollTimeline(engine)○ /engine · how a timeline.engine resolves to an engine / to the browser’s ScrollTimeline — the players’ own decision helpers
Playback defaultsPX_PLAYBACK_DEFAULTS, resolveTriggers(triggers), PxResolvedTriggers, triggerEventList(events), resolveActions(actions), PxResolvedActions, actionSettingsOf(slot) (one slot’s settings, the word form read as its object), resolveGate(view), PxResolvedGate○ what an absent key means (start ‘load’, the start / stop / finish / outOfView behaviors continue · reset · hold · pause, no iteration completion, showWhenStopped ‘static’, threshold 0.5, debounce 150 ms) — the one table every player reads. resolveTriggers normalises the lists to arrays of the events each may name (triggerEventList does one), derives resume unless the file names it (derivedResume), defaults an absent block to { start: ['load'] } and an empty one to nothing; resolveActions defaults every action setting; resolveGate defaults the gate (outOfView behavior, threshold, debounce). /engine: resolveTriggers, PxResolvedTriggers, triggerEventList, resolveActions, PxResolvedActions, resolveGate, PxResolvedGate
Trigger listsPxTriggerList, allowedTriggerEvents(list), counterpartTriggerEvent(event), derivedResume(start, stop, pause), triggerIssues(triggers), PxTriggerIssueKind, PxTriggerIssue○ the lists by name; the events each may name (the schema is built from it); the event that undoes another (hoverIn ↔ hoverOut, click → click); the derived resume; every problem with a block as a PxTriggerIssue (eventNotAllowed · collision · resumeExtra · resumeMissing) — Which events each list may name. /engine: the four functions and PxTriggerIssue
State rulePxPlayState, PxTriggerAction, triggerActionFor(triggers, state, event), triggerListsEvent(triggers, event)○ the player’s state (stopped · playing · paused) picks the list an event is read from, and the answer is play, pause or stop; triggerListsEvent says which listeners to attach — Triggers. /engine: the two functions
Play executorPxPlayExecutor, createPlayExecutor(api, cfg, raf), ensurePlayExecutor(api, cfg, raf), executorConfigOf(view), PxPlayTarget, PxPlayExecutorConfig, composeApiExtras(api, cfg, raf), PxApiExtrasConfig, PxApiExtrasTarget, PxApiExtras, fillModeOf(showWhenStopped, finishBehavior)○ the ONE performer of every built-in action — the triggers, the play prop and the api’s play() / stop() all call it — and the composed api members built on it; fillModeOf is the animation-fill-mode the two wire settings spell, for CSS and the WAAPI engine — see the signature block above. /engine: all but PxPlayExecutor
Time contractseekCeilingMs, progressSpanMs, clampSeekMs, timeToProgress, progressToTimeMs, isValidPlaybackRate, PX_RATE_REJECTED, createRunClock +○ /engine · the one meaning of time, seeking and rate that every engine implements — see PxAnimationPlayerApi
DiagnosticscreateDiagnostics(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 valuesPxTimelineEngineSetting, PxTimelineEngine, PxGradientType, PxUnits, PxGradientSpreadMethod, PxLoopRepeatAt, PxLoopDirection, PxStrokeTrimSubPaths, PxCloneWithout (clone.without → 'translate'), PxMaskType, PxPathOverflow, PxLengthAdjust, PxTextPathMethod, PxTextPathSpacing, PxPlaybackDirection, PxTriggerEvent, PxStartBehavior, PxStopBehavior, PxFinishBehavior, PxOutOfViewBehavior, PxShowWhenStopped, PxFillMode, 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 PxTriggerEvent.click and start?: PxTriggerEvents come from one import. PxFillMode is the one that is not a wire key: the engines’ animation-fill-mode, derived from showWhenStopped and the finish behavior by fillModeOf
Browser spellingcssPlaybackDirection(direction), cssScrollPhase(phase), PxCssPlaybackDirection, PxCssScrollPhase▪ a camelCase format value spelled the way CSS / Web Animations take it (alternateReverse → 'alternate-reverse'), where a player or the editor’s CSS export hands it to the browser
Schema versionPX_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. PX_WIRE_SCHEMA_VERSION (▪) is the bump-site string behind PX_WIRE_VERSION, moved to /internal (review §16)
Schema releaseschemaFieldUniverse, diffFieldUniverse, planSchemaRelease, releaseLogProblems▪ the field inventory and bump rule behind scripts/schema-release.mjs
DiagnosticsdiagnoseDocument(doc) → ({ problems }), reportDocumentDiagnostics(doc, where), PX_UNKNOWN_KEY_ERROR, PX_VALIDATION_ROOT_SEGMENT▪ the load-time check every player runs; call validateDocument instead
ValidationisPxDocument, validateDocument(doc, options?) → PxValidationResult● the shallow guard, and THE one deep check: { valid, errors, findings } (review §15). isValidPxDocument, validateNodeEffects, validateDeprecatedTransformChannels, validateEasingRefs, validateVersionStamp (▪) moved to /internal
Document accessorsgetAnimatorConfig, getChildren, getBindings, getDefinitions○ read a document without knowing its internals. legacyAnimatorOf (▪) is the raw config at the pre-rendered SVG’s meta.animator address — unchecked: cast it with PxAnimatorConfigSchema
Timeline shapenestAnimatorTimeline, PxAnimatorRuntimeConfig○ the runtime view (PxAnimatorRuntimeConfig, what getAnimatorConfig returns) → the nested timeline object. The other direction, resolveAnimatorTimeline (▪), moved to /internal (review §17a): read the view through getAnimatorConfig / prepareDocument
PreparingprepareDocument, documentStage, PxDocStage, + PxPrepareOptions, PxPreparedDocument○ the one lowering pipeline, current → checked → resolved → plain, stamped on the result — Preparing a document. carryPrep (▪) re-stamps a doc a player derived from a prepared one; reportDocumentProblems (▪) turns what checked repaired into a player’s warnings (effectsShape inside effects, else documentRepaired)
Playback overridePxPlaybackOverride, PxTimelinePatch, PxTimelineTiming, PxActionsPatch, PxPatch, PxAnimatorConfigPatch, PxAnimatorConfigMergeResult● the timelineOptions and actions overrides as every player takes them — each a PxPatch (every key optional at every depth, null deletes): of PxTimelineTiming (the timeline minus actionTriggers and actions) and of PxActions — and the companion types of the two merge functions above — Overriding from a player
Override plumbingapplyPlaybackOverride(doc, override), PxPlaybackOverrideInput, PxPlaybackOverrideResult, withActionTriggers(doc, actionTriggers), PxActionTriggersInput (the driver prop’s object form: the lists minus the derived resume)○ /engine · the one implementation of { timelineOptions, actions, playUsingActionTriggers } every player calls — the timing and the actions merged, then the triggers SET as a whole when playUsingActionTriggers is an object — and the SET itself, which an empty block and a replacement need and a merge cannot say
No-driver patchwithoutTriggerDriver(doc)○ /engine · the document’s triggers replaced by an EMPTY block — nothing starts it — what every player applies when playUsingActionTriggers is absent (the components’ non-playUsingActionTriggers control modes use the same helper)
Compiled animationPxCompiledAnimation, PxCompiledKeyframe, PxAnyPropertyAnimation, isCompiledAnimation(entry), PxCompiledAnimationSchema, PxAnyPropertyAnimationSchema, PxRgba, PxRgbaSchema, PxCubicBezier, PxCubicBezierSchema, PxPathValue○ the format’s low layer — Compiled animation. compileAnimation, compileAnimationDefinition, compileAnimationsInTree, PxCompileContext (▪) lower authored entries to it; toAuthoredAnimation, authorCompiledAnimations (▪) read it back as the authored form (the editor’s read path); anyPropertyAnimationSchemaOf (▪) builds the entry union around a widened authored schema (the editor’s); PxNormalizedPathValueSchema (▪) casts a compiled d value ({paths})
Runtime channelsPxNormalizedBinding, PxRuntimeAnimation, PxValueKind○ /engine · what normalizeBindings hands the engines: compiled animations in window ms — see Materializing a document. channelKindOf (▪) is the one place a property’s value kind is decided; mapChannelKeyframes (▪) runs keyframe work with the blend of the channel’s kind (PxChannelLerp ▪); movesAlongPath (▪) says whether a compiled transform is a motion path the frame loop evaluates
Schema toolkitpx, schemaKeys, describeSchema — plus one schema value per wire type: PxDocumentSchema, PxNodeSchema, PxNodeBaseSchema, PxSvgNodeRootSchema, PxAnimatorConfigSchema, PxTimelineSchema, PxTimeTimelineSchema, PxActionTriggersSchema, PxActionsSchema, PxStartActionSchema, PxStopActionSchema, PxFinishActionSchema, PxOutOfViewActionSchema, 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 stagesnormalizeBindings (○), calcAnimationValues (○), materializeMotionPathInPropAnim, mergeStaticTransformIntoAnimDef▪ stages of materializeAllInTree; call the pipeline instead. /engine: normalizeBindings, calcAnimationValues. calcAnimationValues(channels, ms, segments?) takes an optional motion-path segment table (createMotionPathSegments, MotionPathSegments ▪) that its owner — an animator, one pass — keeps, so each curve segment is built once; there is no module-level cache
Effect harnessdiffInEffect▪ the editor’s “equal in effect” comparison
Text & pathsmaterializeGlyphText, layoutGlyphTextChars, createPathSampler, extendedPathForBrowser, materializeGlyphTextAlongPath▪ glyph-text and text-on-path materialization
Node propstoDomProps (○), sanitizeAttributeValue, PX_CSS_ONLY_STYLE_PROPS, PX_DISALLOWED_SVG_TAGS_LOWER▪ shared normalization and sanitization rules. /engine: toDomProps
Scroll mathisScrollTimeline, scrollViewProgress, scrollOffsetProgress, scrollPhaseInterval, scrollResolveAxis, scrollTotalDurationMs▪ scroll-driven playback internals
Maths & stringscubicBezier, 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, deepEquals, generateUniqueId, PX_DEFAULT_DURATION_MS, PX_DEFAULT_ITERATIONS, PX_DEFAULT_UNITS_PER_EM, PX_LOOP_JUMP_SHIFT_MS▪ helpers shared with the editor
Schema toolkit typesPxSchema, PxAnySchema, PxSchemaDesc, PxInfer, PxValidationContext, PxValidationFinding, PxValidationFindingKind, PxRemoveIndex○ the types you build a schema with — see the toolkit above. PxAnySchema is “a schema of some value” (lists, constraints, describeSchema). findings on the context is the structured twin of errors: a kind (unknown key, type mismatch, invalid value, missing value, union mismatch) and a path per line, for code that must branch on what went wrong rather than read it
Companion typesPxMaterializeAllOptions, PxGlyphCharBox, PxAnimatable, GlyphTextNode, GlyphFactory, GlyphPathProps, GlyphPaint, GlyphMotion, GlyphVisibility, GlyphFontsInput▪ argument and result shapes of the functions above. The glyph materializer reads a GlyphTextNode (layout typed, paint carried as the caller’s own type) and builds through the caller’s GlyphFactory
Attribute namesPX_ANIM_ATTR_NAME, PX_ANIM_SRC_ATTR_NAME, PX_TEXT_CONTENT_ATTR▪ reserved keys — see Nodes

Two numbers, unrelated to each other:

NumberLives inMoves when
Package versionevery @pixodesk/svg-animator-* packageevery 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 versionanimator.version in the documentthe file format changes — not on every release

A string "a.b.c":

PartMeaning
aformat generation. Nothing converts across a change of a
bplayer schema revision. A player at a.b reads files of the same a and any b up to its own
ceditor extension — covers meta.* only. The player ignores it

This release reads schema 1.15 (PX_WIRE_VERSION, parsed; formatWireVersion gives the string back). The editor stamps every file it saves ("1.15.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.

A file stamped with an older version is brought up to date when it is read, one step at a time (PX_WIRE_STEPS lists them). Converting is one-way: a newer version can say things an older one could not.

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-way
const down = target && downgradeWireDocument(json, target);
if (down && !down.ok) console.warn(down.reason);
  • relation is one of unstamped · same · older · newer · otherGeneration (PxWireVersionRelation).
  • advice is 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 a reason naming 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:

Terminal window
node scripts/upgrade-document.mjs animation.json # writes animation.upgraded.json
node scripts/upgrade-document.mjs animation.json --out new.json
node scripts/upgrade-document.mjs animation.json --in-place
node scripts/upgrade-document.mjs animation.json --to 1.1 # down-convert

It 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.