SVG
@rootnative/inertia-svg adds animatable SVG primitives built on react-native-svg. It is an optional sibling package — install it only when you need to morph paths or animate fill / stroke. The core library has no required react-native-svg dependency.
MotionPath wraps <Path> and accepts the same initial / animate / transition shape as the core Motion.* primitives, with animatable keys for the path data (d) plus color and numeric paint properties. Prebuilt MotionCircle / MotionRect / MotionLine cover the other common shapes, and the createMotionSvgComponent factory behind them wraps any react-native-svg element.
Install
- Yarn
- npm
- pnpm
- Bun
yarn add @rootnative/inertia-svg react-native-svg
npm install @rootnative/inertia-svg react-native-svg
pnpm add @rootnative/inertia-svg react-native-svg
bun add @rootnative/inertia-svg react-native-svg
react-native-svg works in bare React Native projects as well as Expo.
Usage
import Svg from 'react-native-svg'
import { MotionPath } from '@rootnative/inertia-svg'
function Toggle({ open }) {
return (
<Svg viewBox="0 0 100 100" width={120} height={120}>
<MotionPath
d="M 20 30 L 50 60 L 80 30 L 80 30 L 50 60 L 20 30"
animate={{
d: open
? 'M 20 30 L 50 60 L 80 30 L 80 30 L 50 60 L 20 30'
: 'M 20 50 L 50 20 L 80 50 L 80 50 L 50 80 L 20 50',
fill: open ? '#0ea5e9' : '#1f2937',
}}
transition={{ type: 'spring', tension: 140, friction: 12 }}
fill="#1f2937"
/>
</Svg>
)
}
The package also exports a MotionSvg namespace for autocomplete-friendly grouping — MotionSvg.Path and MotionPath point at the same component, so pick whichever import style matches your codebase:
import { MotionSvg } from '@rootnative/inertia-svg'
;<MotionSvg.Path d={SOURCE_D} animate={{ d: TARGET_D }} />
The namespace also carries the prebuilt shapes: MotionSvg.Circle, MotionSvg.Rect, and MotionSvg.Line — see Shapes below.
The static d prop is required. Its command sequence is locked at first render — every target d you pass via animate or initial must produce the same command letters in the same order after implicit-repeat expansion. Element-wise scalar interpolation is the entire morphing model.
Structural compatibility
Two paths are morphable iff their normalized template (command letters, in order, with case preserved) is equal. Examples:
✅ M 0 0 L 10 10 Z ↔ M 50 50 L 80 80 Z same: M L Z
✅ M 0 0 L 10 10 L 20 20 ↔ M 0 0 50 50 60 60 same: M L L
(implicit repeats after M expand to L)
❌ M 0 0 L 10 10 Z ↔ M 0 0 L 10 10 differs: count
❌ M 0 0 L 10 10 ↔ M 0 0 l 10 10 differs: absolute L vs relative l
❌ M 0 0 L 10 10 ↔ M 0 0 C 1 1 2 2 3 3 differs: L vs C
The component throws in dev when the templates diverge — either at mount (if initial.d mismatches the static d), when animate.d changes shape, or when the static d prop itself changes shape between renders. In production the bad target is skipped instead — the path simply keeps its current d (a mismatched initial.d falls back to the static d) so a single bad value doesn't crash the screen — but you should treat dev errors as bugs.
To switch between structurally different shapes, remount with a new key:
<MotionPath
key={open ? 'hexagon' : 'circle'}
d={open ? HEXAGON_D : CIRCLE_AS_QUADS_D}
/>
Path normalization that resamples between arbitrary shapes (the flubber-style approach) is out of scope for v0.2.
Animatable props
| Key | Shape | Notes |
|---|---|---|
d | string | Path data. Source and target must share the same template — see above. Each scalar param springs / times independently. |
fill | string | Color, interpolated via Reanimated's color setter. Defaults to 'transparent' if neither static nor initial is supplied. |
stroke | string | Same as fill. |
strokeWidth | number | Numeric. Default seed 1. |
strokeOpacity | number | Numeric, 0–1. |
fillOpacity | number | Numeric, 0–1. |
opacity | number | Numeric, 0–1. |
strokeDashoffset | number | Useful for "draw" animations on a dashed stroke. |
Per-property transitions work just like the core primitives:
<MotionPath
d={SOURCE_D}
fill="#000"
animate={{ d: TARGET_D, fill: '#7c3aed' }}
transition={{
d: { type: 'spring', tension: 160, friction: 14 },
fill: { type: 'timing', duration: 300 },
}}
/>
initial
Pass initial to override the mount-frame values (so the component starts somewhere other than the static props), or initial={false} to start at the animate target with no mount animation.
<MotionPath
d={STAR_D}
initial={{ d: TINY_STAR_D, opacity: 0 }} // hatch from a smaller star, fading in
animate={{ d: STAR_D, opacity: 1 }}
/>
<MotionPath
d={STAR_D}
initial={false} // skip the mount animation
animate={{ d: HEART_D }}
/>
If initial.d is provided, it must be template-compatible with the static d — same rule as animate.d.
Reduced motion
MotionPath participates in <MotionConfig reducedMotion> the same way the core primitives do — when the OS reduce-motion setting is on (or you pass reducedMotion="always"), transitions resolve as direct assignment instead of withSpring / withTiming.
Shapes — MotionCircle, MotionRect, MotionLine
Prebuilt animatable wrappers for the common non-path shapes, built with the factory below. Each accepts the same initial / animate / transition shape as MotionPath, and transition also accepts a named transition registered on the nearest <MotionConfig transitions> — top-level and per-property.
| Component | Numeric props | Color props | Array props |
|---|---|---|---|
MotionCircle | cx, cy, r, strokeWidth, strokeOpacity, fillOpacity, opacity, strokeDashoffset | fill, stroke | strokeDasharray |
MotionRect | x, y, width, height, rx, ry, strokeWidth, strokeOpacity, fillOpacity, opacity, strokeDashoffset | fill, stroke | strokeDasharray |
MotionLine | x1, y1, x2, y2, strokeWidth, strokeOpacity, opacity, strokeDashoffset | stroke | strokeDasharray |
The canonical MotionCircle consumer is a circular progress ring — a static strokeDasharray of the circumference with an animated strokeDashoffset:
import Svg from 'react-native-svg'
import { MotionCircle } from '@rootnative/inertia-svg'
const CIRCUMFERENCE = 2 * Math.PI * 45
function ProgressRing({ progress }: { progress: number }) {
return (
<Svg viewBox="0 0 100 100" width={64} height={64}>
<MotionCircle
cx={50}
cy={50}
r={45}
stroke="#0ea5e9"
strokeWidth={8}
fill="none"
strokeDasharray={[CIRCUMFERENCE]}
strokeDashoffset={CIRCUMFERENCE}
animate={{ strokeDashoffset: CIRCUMFERENCE * (1 - progress) }}
transition={{ type: 'timing', duration: 300 }}
/>
</Svg>
)
}
Shape-specific rules (they mirror MotionPath's command-sequence lock):
- Array props lock their length at first render.
strokeDasharrayinterpolates element-wise, so a target with a different length throws in dev and is ignored in production. Remount with a newkeyto change the length. - A key only animates when it's present at mount — in the static props,
initial, oranimate. Keys introduced intoanimateafter mount warn in dev and are ignored. Keys never mentioned pass through as ordinary static props, so un-animated attributes keep their element defaults. - Numeric keys engaged only via
animateseed from0; colors seed from'transparent'. Give the key a static prop or aninitialvalue when the mount animation should start elsewhere.
createMotionSvgComponent
The factory behind the prebuilt shapes. Point it at any react-native-svg element and declare which props animate and how each interpolates:
import { Ellipse } from 'react-native-svg'
import { createMotionSvgComponent } from '@rootnative/inertia-svg'
const MotionEllipse = createMotionSvgComponent(Ellipse, {
animatableProps: ['cx', 'cy', 'rx', 'ry', 'opacity'], // numeric
colorProps: ['fill', 'stroke'], // color strings
arrayProps: ['strokeDasharray'], // numeric arrays, length locked at mount
})
<MotionEllipse
cx={50} cy={50} rx={10} ry={20}
animate={{ rx: 30, fill: '#7c3aed' }}
transition={{ type: 'spring', tension: 180, friction: 14 }}
/>
Prop keys are inferred from the wrapped component, so a typo in animatableProps is a compile error, and the generated component's animate / initial only accept the declared keys. Everything documented for the shapes above — named transitions, per-property transitions, initial semantics, reduced motion, the mount locks — comes from the factory and applies to any component it builds.
MotionPath remains hand-rolled: path morphing needs the d template machinery the generic factory doesn't model.
What this package doesn't do
- Path resampling between structurally different shapes. Same-template morphs only — the rest is your
keyto remount. - Gradient fills inside the SVG. For gradient fills use
<Defs>+<LinearGradient>fromreact-native-svgand animate the stops viaMotionLinearGradient's patterns; the path itself just references the gradient byurl(#id). - Path command interpolation (e.g. morphing an
Linto aC). Element-wise scalar interpolation is intentional — it's predictable, cheap, and matches what designers reach for 95% of the time.
Path utilities
The tokenizer and template helpers are exported for downstream tooling:
import {
parsePathD,
templateOf,
diffTemplate,
flattenParams,
serializePath,
} from '@rootnative/inertia-svg'
const segs = parsePathD('M 0 0 L 10 10 Z') // [{ cmd: 'M', args: [0, 0] }, …]
const t = templateOf(segs) // { cmds: ['M','L','Z'], widths: [2,2,0], size: 4 }
const params = flattenParams(segs) // [0, 0, 10, 10]
const out = serializePath(t, [5, 5, 20, 20]) // 'M 5 5L 20 20Z'
const err = diffTemplate(t, templateOf(parsePathD('M 0 0 L 1 1')))
// → 'command count differs: …'
serializePath is a worklet — call it inside useAnimatedProps if you build your own SVG primitives on top of this layer.