SVG Animation
Any MotionValue can drive an SVG presentation attribute directly. Pass it as a
prop and Motion subscribes to it — no animate prop, no keyframes, and no
component re-render when the value changes.
<script lang="ts">
import { motion, useSpring } from '@humanspeak/svelte-motion'
const cx = useSpring(40)
</script>
<svg viewBox="0 0 300 120">
<motion.circle {cx} cy={60} r={12} fill="#60a5fa" />
</svg><script lang="ts">
import { motion, useSpring } from '@humanspeak/svelte-motion'
const cx = useSpring(40)
</script>
<svg viewBox="0 0 300 120">
<motion.circle {cx} cy={60} r={12} fill="#60a5fa" />
</svg>cx and x2 are both written as presentation attributes via setAttribute — one MotionValue, one channel, exactly how React Framer Motion renders SVG.
Motion 13: accelerated animations commit their final styles
Motion 13 tightened SVG animation completion: when a browser-accelerated animation
finishes, Motion commits its exact final opacity, transform, and paint values to
the SVG element. This matters when an animation fades an element out, when a later
interaction reverses it, or when application state is read immediately after
completion. Without the commit, the picture can look correct for one frame while
computed style still contains the previous value.
<script lang="ts">
import { motion } from '@humanspeak/svelte-motion'
let docked = $state(false)
</script>
<motion.circle
cx={24}
cy={24}
r={10}
initial={false}
animate={{
opacity: docked ? 0 : 1,
transform: docked ? 'translateX(120px)' : 'translateX(0px)',
fill: docked ? '#a855f7' : '#247768'
}}
/><script lang="ts">
import { motion } from '@humanspeak/svelte-motion'
let docked = $state(false)
</script>
<motion.circle
cx={24}
cy={24}
r={10}
initial={false}
animate={{
opacity: docked ? 0 : 1,
transform: docked ? 'translateX(120px)' : 'translateX(0px)',
fill: docked ? '#a855f7' : '#247768'
}}
/>The important detail is initial={false} plus state-driven final values. Once the
transition ends, getComputedStyle(circle) reports the destination values exactly;
reversing the state commits the source values again.
Final styles, actually committed.
The signal-dock demo makes the behavior visible: the ghost ring marks the destination, the packet blooms and fades there, and its telemetry reads the actual browser-computed opacity, transform, and fill. Use Replay and Reverse to exercise both commits.
One DOM channel
Every bound value renders as an SVG presentation attribute via setAttribute — cx, stroke-dashoffset, x1/y1/x2/y2, points,
all of them. Transforms are the one exception: they compose on the style attribute. This is exactly how React Framer Motion renders SVG.
That means the DOM you inspect is the live value — a bound cx moves the
element’s cx attribute every frame, and computed style follows it
(presentation attributes reflect into computed style):
// After cx.set(60):
circle.getAttribute('cx') // "60" <- the live value
getComputedStyle(circle).cx // "60px" <- follows the attribute// After cx.set(60):
circle.getAttribute('cx') // "60" <- the live value
getComputedStyle(circle).cx // "60px" <- follows the attributeOne cascade note: presentation attributes sit at the very bottom of the CSS
cascade, so a stylesheet rule targeting the same geometry property (e.g. circle { cx: … }) wins over the animated attribute — the same trade-off
React Framer Motion makes.
Prop names use the DOM spelling
Svelte templates take the DOM spelling of hyphenated attributes, so write stroke-width, not strokeWidth:
<script lang="ts">
import { motion, useSpring } from '@humanspeak/svelte-motion'
const strokeWidth = useSpring(4)
</script>
<svg viewBox="0 0 120 120">
<motion.circle
cx={60}
cy={60}
r={20}
fill="none"
stroke="#5eead4"
stroke-width={strokeWidth}
/>
</svg><script lang="ts">
import { motion, useSpring } from '@humanspeak/svelte-motion'
const strokeWidth = useSpring(4)
</script>
<svg viewBox="0 0 120 120">
<motion.circle
cx={60}
cy={60}
r={20}
fill="none"
stroke="#5eead4"
stroke-width={strokeWidth}
/>
</svg>Both spellings are accepted, but the kebab-case form is the one you would write
for a plain <circle>, and it is what the rendered markup uses.
Server rendering
A bound attribute is server-rendered with the MotionValue’s current value, so
the first paint is correct and nothing flashes on hydration. Attribute names are
emitted with their DOM spelling — strokeDashoffset becomes stroke-dashoffset, while genuinely camelCase names like viewBox are left
alone.
attrX, attrY, and attrScale
x, y, and scale are ambiguous: each is both an SVG attribute and a CSS
transform that Motion already owns. Passing x moves the element with a
transform. To reach the attribute instead, use attrX, attrY, or attrScale:
<script lang="ts">
import { motion, useMotionValue } from '@humanspeak/svelte-motion'
const attrX = useMotionValue(10)
</script>
<svg viewBox="0 0 300 120">
<!-- renders x="10", not transform: translateX(10px) -->
<motion.rect {attrX} attrY={10} width={40} height={40} />
</svg><script lang="ts">
import { motion, useMotionValue } from '@humanspeak/svelte-motion'
const attrX = useMotionValue(10)
</script>
<svg viewBox="0 0 300 120">
<!-- renders x="10", not transform: translateX(10px) -->
<motion.rect {attrX} attrY={10} width={40} height={40} />
</svg>scale is not a presentation attribute on shape elements. attrScale on a <rect> writes the attribute faithfully, and the rect ignores it — nothing
moves. It is a real attribute on <feDisplacementMap>, where it drives the
displacement amount. This mirrors Framer Motion’s behavior.
attrX/attrY/attrScale are inert on <motion.svg> itself. The
renderer treats the root <svg> tag as HTML-like and returns before the
attribute copy, so a bound attrX on a nested <motion.svg> leaves the x attribute at its server-rendered value. Position nested <svg> elements with x/y instead — they animate as transforms. This also mirrors React Framer
Motion; #456 tracks
lifting the limitation after 1.x.
Drawing paths
pathLength, pathSpacing, and pathOffset are handled separately, as
normalized 0–1 values. They animate the underlying stroke-dasharray and stroke-dashoffset for you:
<motion.path
d="M 10 100 Q 100 10 190 100"
stroke="#5eead4"
fill="none"
initial={{ pathLength: 0 }}
animate={{ pathLength: 1 }}
transition={{ duration: 1.5 }}
/><motion.path
d="M 10 100 Q 100 10 190 100"
stroke="#5eead4"
fill="none"
initial={{ pathLength: 0 }}
animate={{ pathLength: 1 }}
transition={{ duration: 1.5 }}
/>Set pathLength to 0.5 and half the path is drawn, regardless of its actual
length in user units.
Filter primitives
SVG tag names are case-sensitive, and motion components are addressed in
lowercase. motion.fedisplacementmap renders a correctly-cased <feDisplacementMap>:
<script lang="ts">
import { motion, useSpring } from '@humanspeak/svelte-motion'
const warp = useSpring(12)
</script>
<svg viewBox="0 0 300 120">
<filter id="warp">
<feTurbulence type="turbulence" baseFrequency="0.04" numOctaves="2" result="noise" />
<motion.fedisplacementmap in="SourceGraphic" in2="noise" attrScale={warp} />
</filter>
<rect x="40" y="25" width="220" height="70" fill="#38bdf8" filter="url(#warp)" />
</svg><script lang="ts">
import { motion, useSpring } from '@humanspeak/svelte-motion'
const warp = useSpring(12)
</script>
<svg viewBox="0 0 300 120">
<filter id="warp">
<feTurbulence type="turbulence" baseFrequency="0.04" numOctaves="2" result="noise" />
<motion.fedisplacementmap in="SourceGraphic" in2="noise" attrScale={warp} />
</filter>
<rect x="40" y="25" width="220" height="70" fill="#38bdf8" filter="url(#warp)" />
</svg>Filter-primitive attributes are bindable like any other. stdDeviation, baseFrequency, numOctaves, dx, dy, and radius all take a MotionValue:
<script lang="ts">
import { motion, useSpring } from '@humanspeak/svelte-motion'
const blur = useSpring(2)
</script>
<svg viewBox="0 0 300 120">
<filter id="soften">
<motion.fegaussianblur in="SourceGraphic" stdDeviation={blur} />
</filter>
<rect x="40" y="25" width="220" height="70" fill="#4ade80" filter="url(#soften)" />
</svg><script lang="ts">
import { motion, useSpring } from '@humanspeak/svelte-motion'
const blur = useSpring(2)
</script>
<svg viewBox="0 0 300 120">
<filter id="soften">
<motion.fegaussianblur in="SourceGraphic" stdDeviation={blur} />
</filter>
<rect x="40" y="25" width="220" height="70" fill="#4ade80" filter="url(#soften)" />
</svg>The same holds for motion.lineargradient, motion.clippath, motion.textpath, and the rest of the camelCase SVG elements.