@pixodesk/svg-animator-rn
v1.0.30
Published
Pixodesk SVG animator for React Native — renders animator documents via react-native-svg, driven natively by react-native-reanimated
Readme
animator-rn
React Native component for rendering and controlling Pixodesk SVG animations,
built on react-native-svg and react-native-reanimated. Props mirror
@pixodesk/svg-animator-react.
🧪 Status — EXPERIMENTAL
- API may change without a major version bump.
- A few things are unimplemented or unverified — see Feature support.
What it does
<PixodeskSvgAnimator doc={…} /> takes an animation document exported from the
Pixodesk editor — the same .json the web player uses — and renders it as
native SVG, driven on the UI thread.
- One prop to render: put the
.jsonnext to your component, import it, pass it asdoc. - Playback control: declarative props (
autoplay,play,pause,timeMs) or an imperative ref (play(),pause(),setCurrentTime(), …). - Sized by its container: wrap it in a
Viewwith the dimensions you want.
Quick start
Drop animation.json next to your component and render it:
import { View } from 'react-native';
import { PixodeskSvgAnimator } from '@pixodesk/svg-animator-rn';
import animation from './animation.json';
export function Logo() {
return (
<View style={{ width: 200, height: 200 }}>
<PixodeskSvgAnimator doc={animation} autoplay />
</View>
);
}That's the whole common case: an animation that plays on mount and loops if the document says so.
Typical cases
// Play once when a screen opens, then hold the last frame
<PixodeskSvgAnimator doc={doc} autoplay iterations={1} fill="forwards" />
// Loop forever regardless of what the document says
<PixodeskSvgAnimator doc={doc} autoplay iterations="infinite" />
// Static — first frame only, no playback
<PixodeskSvgAnimator doc={doc} />
// Toggle from your own state
<PixodeskSvgAnimator doc={doc} play={isPlaying} />
// Tie progress to a gesture / slider (no playback, just a frame)
<PixodeskSvgAnimator doc={doc} time={scrollProgress} />
// Override the document's timing (duration in ms, delay before start)
<PixodeskSvgAnimator doc={doc} autoplay duration={4000} delay={500} />
// Do something when it finishes
<PixodeskSvgAnimator doc={doc} autoplay onFinish={() => setDone(true)} />TypeScript: import animation from './animation.json' gives you a plain object.
If your tsconfig complains, cast it:
import type { PxAnimatedSvgDocument } from '@pixodesk/svg-animator-core';
const doc = animation as PxAnimatedSvgDocument;Install
npm install @pixodesk/svg-animator-rn
# peers, if you don't have them already:
npx expo install react-native-svg react-native-reanimatedPeer dependencies: react >=18, react-native >=0.76,
react-native-svg >=15, react-native-reanimated >=3.16.
Reanimated needs its Babel plugin. In babel.config.js:
module.exports = function (api) {
api.cache(true);
return {
presets: ['babel-preset-expo'],
plugins: ['react-native-reanimated/plugin'], // must be last
};
};Monorepo users: make sure
react-native-svg,react-native-reanimatedandreactresolve to a single copy. Two copies produceView config getter callback for component 'RNSVGLine' must be a functionat runtime. See Monorepo setup.
Control modes
Four ways to drive playback. Pick one — they are mutually exclusive.
Autoplay
Uses the trigger defined in the animation document (startOn: 'load' plays on mount):
<PixodeskSvgAnimator doc={doc} autoplay />Declarative play/pause
Control playback with boolean props:
const [play, setPlay] = useState(false);
const [pause, setPause] = useState(false);
<PixodeskSvgAnimator doc={doc} play={play} pause={pause} />
<Button title={play ? 'Play (on)' : 'Play (off)'} onPress={() => setPlay(p => !p)} />
<Button title={pause ? 'Pause (on)' : 'Pause (off)'} onPress={() => setPause(p => !p)} />Imperative API
Use a ref for full programmatic control:
import { useRef } from 'react';
import type { RnAnimatorApi } from '@pixodesk/svg-animator-rn';
const api = useRef<RnAnimatorApi>(null);
<PixodeskSvgAnimator doc={doc} apiRef={api} />
<Button title="Play" onPress={() => api.current?.play()} />
<Button title="Pause" onPress={() => api.current?.pause()} />
<Button title="Cancel" onPress={() => api.current?.cancel()} />
<Button title="Finish" onPress={() => api.current?.finish()} />RnAnimatorApi methods: play(), pause(), cancel(), finish(),
isPlaying(), setPlaybackRate(rate), getCurrentTime(), setCurrentTime(ms).
Controlled time
Scrub through the animation or pin a fixed frame:
const [timeMs, setTimeMs] = useState(0);
<PixodeskSvgAnimator doc={doc} timeMs={timeMs} />
<Slider minimumValue={0} maximumValue={2000} value={timeMs} onValueChange={setTimeMs} />Props
| Prop | Type | Description |
|---|---|---|
| doc | PxAnimatedSvgDocument | The animation document to render (required) |
| autoplay | boolean | Use the trigger from the document |
| play | boolean | Start playback, ignoring document triggers |
| pause | boolean | Pause current playback |
| apiRef | RefObject<RnAnimatorApi> | Ref for imperative control |
| time | number | Seek to a fraction (0–1) of the whole timeline (duration × iterations) |
| timeMs | number | Seek to a time in milliseconds |
| duration | number | Duration override (ms) |
| delay | number | Delay before start (ms) |
| iterations | number \| 'infinite' | Loop count |
| fill | FillMode | Fill behaviour |
| direction | PlaybackDirection | Playback direction |
| resetOnFinish | boolean | Snap back to the start after a natural finish |
| outAction | OutAction | What a second tap does with the click trigger (default: the document's, else pause) |
| onPlay | () => void | Called on play/resume |
| onPause | () => void | Called on pause |
| onFinish | () => void | Called on natural finish |
| onCancel | () => void | Called on cancel |
| onStop | () => void | Called whenever playback halts (pause / cancel / finish) |
| onError | (error, componentStack?) => void | Called when a document cannot be compiled or rendered |
| fallback | (error) => ReactElement \| null | Rendered in place of a failed animation (default: nothing) |
With none of autoplay / play / pause / time / timeMs set, the component
renders the animation statically (initial state, no playback).
Failure handling
The component never throws for a bad document. Compilation and rendering both
run inside try/catch, and the rendered tree sits behind an error boundary,
so a failure is reported through onError and shows fallback while the
surrounding screen keeps working.
<PixodeskSvgAnimator
doc={doc}
autoplay
onError={e => console.warn('animation failed:', e.message)}
fallback={() => <Text>could not play this animation</Text>}
/>This covers JavaScript errors. A crash inside react-native-svg's native renderer never reaches JavaScript and cannot be caught — see Known limitations.
Differences from the React package
| Prop | Why it differs |
|---|---|
| mode | Not accepted. There is no Web Animations API on RN; playback is always native-driven. |
| frameRate | Not accepted. Reanimated runs at the display refresh rate. The analogous knob is sampling density — see compileTracks. |
| startOn | Not accepted as a prop — the document's trigger is honoured via autoplay (load, click, scrollIntoView, programmatic). mouseOver has no touch equivalent. |
| className / style | Not accepted. Size the animation with the container View. (node.style inside the document is supported.) |
| onRemove | Not emitted. Use React's own unmount cleanup. |
How playback works
There is no JavaScript frame loop — the JS thread is idle while an animation runs.
- Once per document: the shared core flattens it
(
materialiseAllInTree→ effects, loops, motion-path sampling, animated<use>inlining), then a track compiler densely samples every animated property withcalcAnimationValues— the same function the web frames engine renders with, so values match the web player exactly. - Per frame: one reanimated progress value, driven by
withTiming/withRepeaton the UI thread, and a tiny worklet per animated element that indexes its precompiled track.
This is why sampling appears so often below: where react-native-svg cannot
express something directly (motion along a path, text on a path), the core
converts it into plain values ahead of time instead of fighting the platform.
Feature support
Every row below was verified by running the document through the real
pipeline (materialiseAllInTree → track compilation) and checking that the
element maps to a react-native-svg component and that its animated
properties actually change over time.
Elements
| Element | Renders | Notes |
|---|---|---|
| svg, g, defs | ✅ | |
| rect, circle, ellipse, line, path, polygon, polyline | ✅ | |
| text, tspan | ✅ | content via the text attribute |
| textPath | ✅ | see Text along a path below |
| image | ✅ | href accepts data: URIs; remote URLs are blocked by the sanitiser |
| use, symbol | ✅ | animated targets are inlined into real clones before render — <use> does not propagate animation natively in RN |
| linearGradient, radialGradient, stop | ✅ | |
| pattern, marker | ✅ | static geometry verified; complex cases unverified on device |
| mask, clipPath | ✅ | |
| filter + all 22 fe* primitives | ✅ | feGaussianBlur, feDropShadow, feColorMatrix, feMerge, feComponentTransfer + feFunc*, … Requires the New Architecture; visual parity with the web is unverified on device |
| foreignObject | ❌ | blocked by the shared sanitiser (embeds arbitrary host content) |
| script | ❌ | blocked by the shared sanitiser |
Animatable attributes
| Attribute | Animates | Notes |
|---|---|---|
| opacity, fill-opacity, stroke-opacity | ✅ | |
| fill, stroke, stop-color | ✅ | interpolated as RGBA |
| stroke-width, stroke-dasharray, stroke-dashoffset | ✅ | dash arrays are converted to the numeric form RN expects |
| x, y, width, height, cx, cy, r, rx, ry | ✅ | |
| d (path morphing) | ✅ | keyframes must share command structure |
| transform (unified parts record) | ✅ | translate, rotate, skew, scale, origin |
| translate / rotate / scale (legacy per-key form) | ✅ | |
| offset and stop-color on gradient stops | ✅ | |
| filter primitive attrs (e.g. stdDeviation) | ✅ | compiles correctly; on-device rendering unverified |
| font-size | ✅ | |
| Any other numeric SVG attribute | ✅ | interpolated numerically and written straight through |
Effects (node.effects)
All effects are materialised by the shared core before rendering, so the RN player sees plain nodes. All are supported:
| Effect | Status | Notes |
|---|---|---|
| transformBy | ✅ | all parts animatable, including skew |
| repeater | ✅ | copies materialised as real elements; per-copy params animatable |
| maskedBy | ✅ | including an animated mask source |
| clipPath | ✅ | including animated clip geometry |
| strokeTrim | ✅ | incl. offset and subPaths: 'combined' |
| clone + retime | ✅ | each clone keeps its own time shift, incl. retime.timeCrop (a visibility window on the document timeline) |
| fillGradient / strokeGradient | ✅ | animated stops and animated geometry (animate.gradientX1/Cx/R, …); gradientTransform is static (core-wide) |
| textPath | ✅ | incl. animated startOffset |
| text.useGlyphs | ✅ | text becomes <path> outlines from definitions.glyphs — no font needed |
| isCombinedShape | ✅ | |
Motion, timing and references
| Feature | Status | Notes |
|---|---|---|
| Motion along a path + autoOrient | ✅ | sampled by the core into plain transform keyframes — react-native-svg has no native path motion |
| Text along a path | ✅ two ways | native textPath (incl. animated startOffset), or per-letter motion paths for smooth results — the example app uses the latter, since animating native startOffset is janky in react-native-svg |
| Per-property loop (incl. alternate pingpong) | ✅ | expanded before playback |
| Easing (cubic-bezier and named refs) | ✅ | baked into the sampled tracks |
| definitions.animations / easings / styles / glyphs | ✅ | named refs resolved; style presets applied as props |
| node.style (inline or named) | ✅ | resolved to props — RN has no CSS, so explicit attributes win |
Playback and triggers
| Feature | Status | Notes |
|---|---|---|
| duration, delay, iterations (incl. 'infinite') | ✅ | |
| direction — all four values | ✅ | |
| fill — forwards / backwards / both / none | ✅ | |
| resetOnFinish | ✅ | |
| play / pause / cancel / finish | ✅ | |
| setCurrentTime — seek, including while playing | ✅ | playback continues from the new position |
| setPlaybackRate — faster, slower and reverse (negative) | ✅ | composes with direction |
| Trigger load / programmatic | ✅ | |
| Trigger click | ✅ | wrapped in a Pressable; a second tap applies outAction |
| Trigger scrollIntoView | ✅ | visibility sampled by measuring against the window (RN has no IntersectionObserver); honours scrollIntoViewThreshold and outAction |
| Trigger mouseOver | ❌ | no touch equivalent — use click, or drive play yourself |
| frameRate | n/a | reanimated runs at the display refresh rate; use compileTracks({sampleRate}) to trade memory for temporal precision |
| mode (waapi / frames) | n/a | there is no Web Animations API on RN — playback is always native-driven |
Known limitations
- On-device verification is incomplete. The pipeline, prop mapping and
driving logic are covered by unit tests and were exercised end-to-end
through
react-native-web; the native reanimated ↔react-native-svgprop bridge (notably filters andstrokeDasharray) still needs checking on real iOS/Android. - animated
gradientTransformis unimplemented (retime.timeCropnow works — implemented 2026-08) in the core, so they are unavailable here too. mouseOverhas no touch analogue and will not be implemented.- Text on a closed path is worked around, not fixed. react-native-svg's
native text-on-path layout crashes the app (an
NSRangeExceptionon iOS) when a<textPath>has a non-zerostartOffseton a closed path: it bounds glyph placement bystartOffset … startOffset + pathLengthinstead of0 … pathLength, so glyphs past the end of the path reach a lookup that returnsNSNotFound. On native the player gives such a<textPath>its own open copy of the path (openClosedTextPathTargets), which restores the correct bounds. Text that would have wrapped around past the end of the loop is clipped instead. Web is unaffected and left untouched.
Monorepo setup
pnpm and yarn workspaces can install two physical copies of a native package when peer versions differ even slightly. If the copy this player imports is not the copy the app registered natively, you get:
Invariant Violation: View config getter callback for component `RNSVGLine`
must be a function (received `undefined`)Two things prevent it:
- Keep
@types/react,reactandreact-nativeversions aligned across every workspace package. - Force single instances in
metro.config.js:
const SINGLETONS = ['react', 'react-dom', 'react-native', 'react-native-svg',
'react-native-reanimated', 'react-native-worklets'];
const base = config.resolver.resolveRequest;
config.resolver.resolveRequest = (context, moduleName, platform) => {
if (SINGLETONS.some(n => moduleName === n || moduleName.startsWith(n + '/'))) {
return context.resolveRequest(
{ ...context, originModulePath: path.join(projectRoot, 'index.js') },
moduleName, platform);
}
return (base ?? context.resolveRequest)(context, moduleName, platform);
};A complete config is in
examples/react-native-preview-player/metro.config.js.
Advanced exports
For custom rendering or diagnostics:
renderRnNode(node, opts)— render aPxNodetree toreact-native-svgelements, with adecoratehook for wrapping animated elements.compileTracks(doc, { sampleRate, maxSamples, native })— build the sampled tracks yourself;sampleRatetrades memory for temporal precision (default 60/s).nativeselects the value form: the default is the SVG/DOM one,truegives what the native views want (atransformbecomes a 6-number matrix).sampleProps(tracks, tMs, stepMs, sampleCount, native)— the worklet-safe lookup.nativerenamestransformto the native views'matrix; pass it only for values going through reanimated's animated-props path on a device.openClosedTextPathTargets(doc, warnings?)— the closed-path<textPath>workaround described under Known limitations.PxRnErrorBoundary— the boundary the component wraps itself in.RN_SVG_COMPONENTS,toRnPropName— the tag and attribute maps.
Example app
A full preview player with six animations and transport controls:
pnpm example:rn # or: cd examples/react-native-preview-player && npx expo startSee examples/react-native-preview-player.
