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",
// Starts once half of it is on screen; pauses when it scrolls away
"trigger": { "start": "load", "offScreen": "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 the kind, 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 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 helpers the players decide this with, so a player you write yourself gets the same answer: 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, visibilityThreshold and visibilityDebounce. An animation nobody can see does not play, whatever started it.

They are kept separate so that every combination is possible: 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.

To start an animation when it scrolls into view, use start: 'load' with offScreen: 'pause'. Both are the defaults, so a document with no trigger at all already behaves this way:

  1. The animation waits on its first frame until enough of it is on screen (visibilityThreshold, half by default).
  2. Then it plays.
  3. When it scrolls out of view, it pauses.
  4. When it comes back, it carries on from where it paused.

start: 'load' only says “nobody has to click or hover”. Whether it may actually run is decided by offScreen, described next.

The defaults are the PX_TRIGGER_DEFAULTS constant, and resolveTrigger fills them into a trigger that leaves fields out. Every player uses this same function, so they all read a missing field the same way.

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. An element taller than the screen counts as fully visible once it fills the screen, so a threshold of 0.5 or 1 can always be met. 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
pause (default)pause where it is; re-entering resumes
continuekeep playing
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 use it — the same file shown twice at two speeds, or a file that starts by itself everywhere except inside your own play/pause 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

Every player uses the same merge code from core. 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 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.

When you control a component with play, pause, progress or time, the component ignores the trigger saved in the file (it acts as if start were 'none'). Only autoplay mode follows the file’s trigger. apiRef is not a control prop: the handle is filled in every mode and never changes which one you are in.

React, Vue and React Native decide this with the same core function, so they always agree. resolveControlMode looks at the control props you passed (PxControlProps) and returns the mode that wins (PxControlMode), plus a ready-made warning if you passed props from more than one mode. controlModeTakesOverTrigger then says whether that mode ignores the file’s trigger — true for 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.