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>
mode · live running open
one MotionValue Bound straight to SVG attributes — no keyframes
stroke-dashoffset progress ring
0%
cx & x2 one attribute channel

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.

mode · live running open
vector relay / motion 13

Final styles, actually committed.

uplink ready
SOURCE / 00DOCK / 01
opacity1
transformnone
fillrgb(36, 119, 104)
commitSOURCE

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 setAttributecx, 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 attribute

One 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.