Skip to content

Playback settings & triggers

Change how an animation plays — its length, loops, direction, what starts it — without going back to the editor. Everything about when and how it plays lives in one place, the document’s animator block, and every player lets you override it at runtime from component props or the player API. This page is the reference for those fields and the overrides.

The editor writes the same block from its playback panel; if you only want to set the defaults there, see Set default playback settings & triggers.

A document with its animator block. (The comments are explanatory; JSON does not allow comments, so a real file has none.)

{
"type": "svg",
"viewBox": "0 0 400 400",
// Everything about WHEN and HOW the animation plays lives here
"animator": {
"timeline": {
"duration": 2000,
"iterations": "infinite",
"direction": "alternate",
"trigger": { "start": "scrollIntoView", "mouseOut": "pause", "visibilityThreshold": 0.5 }
}
},
"children": [
{
"type": "circle",
"id": "ball",
"cx": 0, "cy": 0, "r": 40, "fill": "#0087ff",
"animate": {
"translate": {
"keyframes": [
{ "time": 0, "value": [200, 60], "easing": [0.33, 0, 0.67, 0.33] },
{ "time": 2000, "value": [200, 340] }
]
}
}
}
]
}

The same bouncing ball as in the web player, now two seconds per bounce and waiting until half of it has scrolled into view.

The timeline — what advances the playhead

Section titled “The timeline — what advances the playhead”

animator.timeline says what drives the animation’s progress, exactly like a Web Animations API timeline. Its type picks one of three, mirroring WAAPI’s DocumentTimeline / ScrollTimeline / ViewTimeline:

timeline.typeThe playhead follows…
time (default — may be omitted)wall time — something starts it (the trigger), and it has the playback dynamics below
scrolla scroll container’s offset — Scroll-driven playback
viewthe SVG’s journey through the viewport — Scroll-driven playback

Each type carries only the fields that mean something for it — a scrubbed timeline has no trigger or delay, and the format gives them no slot there. Omitting timeline entirely means a time-driven timeline with every default.

Timing, the playback dynamics, how the animated attributes get updated and at what rate ALL live in the timeline. animator itself keeps only what is not playback: the lookup tables (definitions, bindings) and the debugGlobalName handle.

FieldValuesDefaultMeaning
timeline.durationms1000length of one pass of the timeline. Keyframe times are absolute offsets within it
timeline.frameRatefpsuncappedtarget rate for the player’s frame loop only — a parameter of the engine timeline.engine selects, so it sits beside it
timeline.engineauto · native · jsautohow the animated attributes get updated — Engine
timeline.delayms0wait this long, then start. A negative value skips ahead instead: -500 starts right away from the frame at 0.5 s, as if the animation had already been running for half a second
timeline.iterationsnumber · "infinite"1how many times the whole document timeline repeats
timeline.directionnormal · reverse · alternate · alternate-reversenormalalternate ping-pongs on every other iteration
timeline.fillModeforwards · backwards · both · noneforwardswhat is shown outside the active time: forwards holds the last frame after the end; backwards shows the first frame during the delay; none reverts to the static SVG
timeline.trigger.finishhold · resetholdafter a natural finish: keep the end state (per timeline.fillMode), or snap back to the start

Per-property loops vs iterations. There are two kinds of repetition, and they work at different levels. iterations repeats the whole document — every element, from the first keyframe to the last. A single property can also loop on its own: a segment of its own keyframes repeats until it fills the document’s duration, while everything else plays through once (see JSON format → Per-property loops). The property loop is applied first, when the document is prepared; iterations then repeats the result. So both can be used at once, and one runs inside the other: a wheel whose rotation loops, inside a document set to infinite iterations, keeps spinning during every iteration.

timeline.engine says how the animated attributes get updated — the same three values on every timeline type:

ValueTime-driven timelineScroll / view timeline
auto (default)the Web Animations API — played by the browser itself, so it stays smooth even while the page is busy — with an automatic fallback to the player’s frame loop when the document animates something WAAPI cannot express (path morphing, gradient geometry, filters, text on a path, …)the browser’s own ScrollTimeline / ViewTimeline where supported; otherwise the player measures scroll progress itself and drives WAAPI (or the frame loop, if WAAPI declines the document)
nativeWeb Animations API onlythe browser’s ScrollTimeline / ViewTimeline driving WAAPI (where unsupported, the player measures progress instead — WAAPI stays)
jsa requestAnimationFrame loop that writes attributes every frame; honors frameRate; universal browser supportthe player measures scroll progress and applies values through its frame loop — identical everywhere

Leave it on auto unless you need a guarantee — for instance js for path morphing in Safari < 18.5. React Native ignores engine (playback is always native-driven).

Core exports the three helpers the players decide this with, so a player of your own lands on the same answer rather than a similar one: resolveTimelineEngine turns the document’s setting into the engine that will actually run, isNativeForced says whether native was asked for outright, and mayUseNativeScrollTimeline whether a scroll timeline may be handed to the browser’s own ScrollTimeline.

Triggers — what starts it, and whether it may run

Section titled “Triggers — what starts it, and whether it may run”

The trigger block — inside the clock timeline — answers two independent questions, and every player honors both:

  • What STARTS it — start.
  • Whether it may RUN — offScreen and the two visibility* values. An animation nobody can see does not play, whatever started it.

They are separate on purpose, so every combination is sayable: start on click and pause when scrolled away. The editor writes the block from its Start setting.

"timeline": { "trigger": { "start": "mouseOver", "mouseOut": "reset" } }
startStarts when…Editor label
load (default)the animation is displayed and visible enough — see visibilityThresholdOn load
mouseOverthe pointer enters the elementOn mouse over
clickthe element is clicked; a second click pausesOn click
nonenever by itself — you call play()Manually from JS

A document with no trigger, or a trigger without start, starts on load. Use none when your own code should start it.

There is no scrollIntoView here, because visibility is not a trigger. start: 'load' behind the default gate below is scroll-into-view: it holds at the first frame until enough is on screen, plays, pauses when it leaves, and resumes when it comes back.

Those defaults are PX_TRIGGER_DEFAULTS, and resolveTrigger fills them into a trigger that leaves fields out — one resolution every player shares, instead of four that drift apart.

offScreenEffect while none of it is on screen
pause (default)pause where it is; it resumes when the element comes back
continuekeep playing — and start without waiting to be seen at all
resetjump back to the start, so the next entry replays it from the beginning
FieldDefaultMeaning
visibilityThreshold0.5how much of the element must be on screen before it may run: 0 any part, 0.5 half of it, 1 all of it. Playback stops only at zero visibility, so an element resting on the boundary cannot flicker
visibilityDebounce150and for how long, in ms. Scrolling straight past an animation therefore starts nothing; 0 starts the moment the threshold is met

A hidden browser tab counts as off screen. Where nothing can measure visibility — a server render, a test environment without IntersectionObserver — the animation plays as if fully visible, rather than silently never playing.

mouseOut is read only for start: 'mouseOver':

mouseOutEffect
continue (default)keep playing
pausepause where it is; re-entering resumes
resetjump back to the start
reverseplay backwards to the start

Where triggers work:

  • Every player — web, React, Vue and React Native — supports all of them, with one exception: React Native has no mouseOver, because there is no hover on a touch screen.
  • Pre-rendered SVG + CSS animation + JS triggers supports all of them too. The editor writes a few lines of script into the file for this; no library is involved.
  • Pre-rendered SVG + CSS animation (no script at all) supports load, and mouseOver through CSS :hover. click and the visibility gate cannot be done in pure CSS, so in that flavor the animation starts as soon as it is shown. See Pre-rendered SVG.

Example: playback/override-web — pnpm example:docs, then open #playback/override-web. Example: playback/override-react — pnpm example:docs, then open #playback/override-react.

One document can play differently in each place you mount it — twice on the same page at two speeds, or a file that autostarts everywhere except inside your own transport UI. Every player takes the same override: a timeline object shaped exactly like the document’s animator block, deep-merged over what the file says. The file on disk is never modified.

Web player

<div id="box" style="width: 300px; height: 300px"></div>
import { createAnimator } from '@pixodesk/svg-animator-web';
const a = createAnimator({
src: '/bouncing-ball.json',
container: '#box',
timeline: { iterations: 'infinite', trigger: { start: 'none' } },
});
a.play();

React / Vue / React Native — the same object, as a prop:

<PixodeskSvgAnimator doc={doc} autoplay
timeline={{ iterations: 'infinite', trigger: { start: 'none' } }} />
Objects merge, key by keytimeline: { duration: 2000 } changes the duration and leaves iterations, trigger and everything else as the file has them
Values replacenumbers, strings and arrays are taken as given, never combined
null deletes{ timeline: { delay: null } } removes the file’s delay, restoring what its absence means. This is the only way to get a default back, because there is no value that spells “unset”
Changing timeline.type starts overswitching between a clock and a scroll timeline keeps only duration, iterations, engine and frameRate — the keys both kinds share. Clock-only keys (trigger, delay, fillMode, direction) have no meaning on a scroll timeline and are dropped, with a console warning

The merge itself is core’s, not each player’s re-implementation. applyAnimatorConfig applies a patch to a whole document; mergeAnimatorConfig does the same one level down, on the animator block alone, and reports what it could and could not do in a PxAnimatorConfigMergeResult. Every player calls foldTimelineOverride before either of them — it folds the four shortcuts below into the patch and parses the JSON-string form. A patch is a PxAnimatorConfigPatch, and the timeline part of one a PxTimelinePatch.

The keys people reach for most also exist as plain props / options, because duration={2000} reads better than a nested object:

ShortcutSame as
durationtimeline.duration
delaytimeline.delay
iterationstimeline.iterations
starttimeline.trigger.start

A shortcut wins over the same key inside timeline, the way an inline style beats a stylesheet.

timeline edits what the file says. To ignore the file’s playback settings and start from the player’s own defaults, add resetTimeline:

<PixodeskSvgAnimator doc={doc} resetTimeline timeline={{ duration: 3000 }} />

That plays for 3 s with default timing whatever the file declares. Fonts and the per-element animation tables (definitions, bindings) are always kept — they are the animation itself, not playback settings.

The components switch the trigger to programmatic whenever you use play / pause / progress / time, so only autoplay mode uses the trigger saved in the file. apiRef is not a control prop — the handle is filled in every mode and never changes which one you are in.

One shared rule decides that, so React, Vue and React Native cannot answer it three ways: resolveControlMode reads the control props — typed PxControlProps — and returns the PxControlMode that wins, together with a ready-made sentence for any conflict between two tiers. controlModeTakesOverTrigger then says whether that mode must take the document’s own trigger over, which is true of every mode except autoplay.

Mangled builds. timeline also accepts a JSON string — timeline='{"duration":2000}' — which survives a build that renames object keys. See Minification.

"animator": { "debugGlobalName": "heroBanner" } makes the player publish its API object as window.heroBanner, so you can drive a live instance from the browser console — heroBanner.pause(), heroBanner.setCurrentTime(500), and so on. Purely a debugging convenience; remove it (or leave it — it has no other effect) for production files.

In development. Scroll-driven playback is not finished yet: the fields below may change, and not every combination works in every player. Time-driven playback — the default — is not affected.

Instead of playing on a clock, the animation can follow the scroll position — the playhead moves as the user scrolls: scroll down and the animation goes forward, scroll back up and it goes backward, stop and it stays on that frame. This is the model of CSS scroll-driven animations. Choose Timeline → scroll in the editor’s playback panel, or set it in the document:

"animator": {
// The playhead follows the SVG's journey through the viewport instead of the clock;
// `duration` is the keyframe span the scroll range maps onto.
"timeline": { "type": "view", "duration": 3000, "range": { "start": { "phase": "entry", "fraction": 0 }, "end": { "phase": "exit", "fraction": 1 } } }
}

timeline: { "type": "view" } alone means “show the whole animation, first frame to last, as the SVG travels across the viewport — the scroll position, not the clock, decides which frame is on screen”; type: "scroll" follows the scroll container’s offset instead. The clock fields (trigger, delay, "infinite") have no slot in these timelines. The rest of the object tunes it:

timeline.ValuesMeaning
typeview · scrollprogress = the SVG’s journey across the scrollport, or the scroll container’s offset ratio
axisblock (default) · inline · x · ywhich axis; block = vertical in normal writing mode
sourcenearest (default) · rootfor type: scroll — the nearest scrollable ancestor, or the document
subjectparent · scroller · a CSS selectorfor type: view — whose journey is measured (default: the <svg> itself). parent is what makes a pinned section work
range.start / range.end{ phase, fraction }the slice of the journey mapped to 0–100 %; phase ∈ cover (default) · contain · entry · exit · entry-crossing · exit-crossing; fraction is a position within that phase, 0 = its start, 1 = its end
iterationsnumberthe animation repeats this many times across the range (finite only — "infinite" cannot map onto a range)
smoothingmscatch-up lag — the playhead eases toward the scroll position instead of snapping (smoother under momentum scrolling)
pintrue · { align, offset, distance }hold the canvas still on screen while scrolling moves the animation forward and back (position: sticky); align ∈ top/center/bottom, offset in px from the aligned position, distance in viewport heights creates the scroll travel
engineauto (default) · native · jswho computes progress and applies values — see Engine: auto/native use the browser’s ScrollTimeline where supported, js measures itself

Support: the web player (both engines, and therefore React and Vue), and the SVG + JS animation export. Not yet: the CSS export or React Native. The complete “scrollytelling” pattern is subject: "parent" + pin: true inside a tall section.