Custom effects
Custom effects teach animate() how to drive non-DOM subjects. Claim a kind of plain JavaScript object, describe how its properties are read and written, and it gets Motion’s springs, keyframes, timing, and playback controls.
import { animate } from '@humanspeak/svelte-motion'
import { dialEffect } from './dialEffect'
animate.addEffect(dialEffect)
animate(dial, { angle: 270, radius: 80 }, { type: 'spring' })import { animate } from '@humanspeak/svelte-motion'
import { dialEffect } from './dialEffect'
animate.addEffect(dialEffect)
animate(dial, { angle: 270, radius: 80 }, { type: 'spring' })Writing an effect
createEffect(addValue, { test, read, step }) creates an effect for one subject type. The addValue callback binds each property to the MotionValueState shared by that subject:
import { createEffect, frame } from '@humanspeak/svelte-motion'
type Dial = { angle: number; radius: number }
const dialEffect = createEffect<Dial>(
(dial, state, key, value) =>
state.set(
key,
value,
() => {
(dial as Record<string, number>)[key] = state.latest[key] as number
},
undefined,
false
),
{
test: (subject): subject is Dial =>
typeof subject === 'object' && subject !== null && 'angle' in subject && 'radius' in subject,
read: (dial, key) => (dial as Record<string, number>)[key],
step: frame.preRender
}
)import { createEffect, frame } from '@humanspeak/svelte-motion'
type Dial = { angle: number; radius: number }
const dialEffect = createEffect<Dial>(
(dial, state, key, value) =>
state.set(
key,
value,
() => {
(dial as Record<string, number>)[key] = state.latest[key] as number
},
undefined,
false
),
{
test: (subject): subject is Dial =>
typeof subject === 'object' && subject !== null && 'angle' in subject && 'radius' in subject,
read: (dial, key) => (dial as Record<string, number>)[key],
step: frame.preRender
}
)The full state.set(key, value, render, computed, useDefaultValueType) call stores the bound value, schedules render when it changes, optionally tracks a computed dependency, and controls Motion’s default property-specific value conversion.
| Option | Description |
|---|---|
test | Claims matching subjects passed to animate(). |
read | Seeds the first keyframe from the subject. Return undefined to require explicit [from, to] keyframes. |
step | Selects the frameloop phase. It defaults to frame.render; use frame.preRender when your render loop runs in frame.render. |
Registering with animate
Register the effect once, at module scope, alongside the effect itself:
import { animate, createEffect } from '@humanspeak/svelte-motion'
export const dialEffect = createEffect<Dial>(/* … */)
animate.addEffect(dialEffect)import { animate, createEffect } from '@humanspeak/svelte-motion'
export const dialEffect = createEffect<Dial>(/* … */)
animate.addEffect(dialEffect)The registry is process-global and dedupes by effect identity instead of counting registrations, so a single removeEffect unregisters an effect for the whole application no matter how many places registered it. Registering from a component’s lifecycle is therefore unsafe: with two components sharing one effect, the first teardown unregisters it for the second, and animate() then falls back to the plain-object animator silently rather than failing. Keep addEffect at module scope, and reach for removeEffect only when tearing down an integration you own outright.
When multiple effects match, the most recently added effect wins. DOM elements are never claimed by registered custom effects, and plain objects that no effect claims fall back to Motion’s object animator.
Binding values manually
Effects can also bind values without going through animate(). Calling effect(subject, { x }) returns an unbind function, while effect.get(subject, 'x') returns the bound value:
import { motionValue } from '@humanspeak/svelte-motion'
const angle = motionValue(0)
const unbind = dialEffect(dial, { angle })
dialEffect.get(dial, 'angle')?.on('change', (latest) => {
console.log(latest)
})
unbind()
angle.destroy()import { motionValue } from '@humanspeak/svelte-motion'
const angle = motionValue(0)
const unbind = dialEffect(dial, { angle })
dialEffect.get(dial, 'angle')?.on('change', (latest) => {
console.log(latest)
})
unbind()
angle.destroy()propEffect provides the same .get(subject, key) lookup for manually bound object properties.
Three.js and vgpu
Motion ships adapters for Three.js and vgpu. Import them from this package’s subpaths — three/vgpu stay your own dependencies:
import { animate } from '@humanspeak/svelte-motion'
import { threeEffect } from '@humanspeak/svelte-motion/three'
animate.addEffect(threeEffect)
animate(mesh, { rotateY: 360, scaleX: 1.4 }, { type: 'spring' })
animate(material.uniforms, { progress: 1 })import { animate } from '@humanspeak/svelte-motion'
import { threeEffect } from '@humanspeak/svelte-motion/three'
animate.addEffect(threeEffect)
animate(mesh, { rotateY: 360, scaleX: 1.4 }, { type: 'spring' })
animate(material.uniforms, { progress: 1 })Related
- Vanilla motion values — component-free values and element effects
- Three.js effect — animate meshes, materials, and shader uniforms
- Custom effects example — a canvas dial with complete source
Based on Motion 13.2’s animate.addEffect and createEffect.