useTransform
Creates a motion value that transforms the output of one or more other motion values.
useTransform creates a new motion value that transforms the output of one or more other motion values.
const x = useMotionValue(1);
const y = useMotionValue(1);
const z = useTransform(() => x.get() + y.get()); // z.get() === 2 Usage
Import useTransform from motion-sv:
import { useTransform } from "motion-sv"; useTransform can be used in two ways: with a transform function, or by mapping a value from one range to another:
// Transform function
const doubledX = useTransform(() => x.get() * 2);
// Value mapping
const color = useTransform(x, [0, 100], ["#f00", "#00f"]); Like other hooks, call useTransform during component setup, at the top level of the <script>.
Transform function
A transform function is a normal function that returns a value:
const doubledX = useTransform(() => x.get() * 2); useTransform subscribes to every motion value the function reads with get(). When one of them changes, the function runs again on the next animation frame to compute the new value.
It also runs again when Svelte state that it reads changes. Here, the ball bobs up and down with useTime, and the slider sets how far it travels:
<script lang="ts">
import { motion, useTime, useTransform } from "motion-sv";
let distance = $state(60);
const time = useTime();
const y = useTransform(() => Math.sin(time.get() / 500) * distance);
</script>
<motion.div style={{ y }} />
<input type="range" min="0" max="80" bind:value={distance} /> <script lang="ts">
import { motion, useTime, useTransform } from "motion-sv";
let distance = $state(60);
const time = useTime();
// Recomputes every frame as `time` changes, and whenever `distance` changes
const y = useTransform(() => Math.sin(time.get() / 500) * distance);
</script>
<div class="stack">
<div class="stage">
<motion.div class="ball" style={{ y }} />
</div>
<label>
Distance
<input type="range" min="0" max="80" bind:value={distance} />
</label>
</div>
<style>
.stack {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
}
.stage {
display: grid;
place-items: center;
height: 200px;
}
.stage :global(.ball) {
width: 40px;
height: 40px;
border-radius: 50%;
background: #ff3e00;
}
label {
display: flex;
align-items: center;
gap: 0.75rem;
font-size: 14px;
}
input {
accent-color: #ff3e00;
}
</style> You can also pass the motion values to transform first, and receive their latest values as arguments. Pass one motion value to receive its latest value, or an array of them to receive an array of their latest values:
const doubledX = useTransform(x, (latestX) => latestX * 2);
const area = useTransform([width, height], ([latestWidth, latestHeight]: number[]) => latestWidth * latestHeight); These forms recompute when Svelte state the function reads changes too.
Value mapping
useTransform can also map a single motion value from one range of values to another.
To illustrate, take this x motion value:
const x = useMotionValue(0); You can use useTransform to create a new motion value called opacity:
const opacity = useTransform(x, input, output); By defining an input range and an output range, you can define relationships like "when x is 0, opacity is 1. When x is 100 pixels either side, opacity is 0":
const input = [-100, 0, 100];
const output = [0, 1, 0]; Both ranges can be any length, but they must be the same length as each other.
The input range must always be a series of increasing or decreasing numbers.
The output range must contain values of the same type, but they can be in any order. It can be any value type Motion can animate, like numbers, units, colors and other strings.
const backgroundColor = useTransform(x, [0, 100], ["#f00", "#00f"]); Output ranges can also drive SVG attributes and path drawing. Here, the box's x sets the stroke color of a path, and how much of it is drawn:
<script lang="ts">
import { motion, useMotionValue, useTransform } from "motion-sv";
const x = useMotionValue(0);
const color = useTransform(x, [-100, 0, 100], ["rgb(211, 9, 225)", "rgb(68, 0, 255)", "rgb(3, 209, 0)"]);
const tickPath = useTransform(x, [10, 100], [0, 1]);
</script>
<motion.div drag="x" dragConstraints={{ left: 0, right: 0 }} dragElastic={0.5} style={{ x }}>
<svg viewBox="0 0 50 50">
<motion.path d="M14,26 L 22,33 L 35,16" fill="none" stroke={color} style={{ pathLength: tickPath }} />
</svg>
</motion.div> Drag the box right to draw the tick, or left to draw a cross from two more paths. x maps to the background gradient too:
<script lang="ts">
import { motion, useMotionValue, useTransform } from "motion-sv";
const x = useMotionValue(0);
const xInput = [-100, 0, 100];
const background = useTransform(x, xInput, [
"linear-gradient(180deg, #ff008c 0%, rgb(211, 9, 225) 100%)",
"linear-gradient(180deg, #7700ff 0%, rgb(68, 0, 255) 100%)",
"linear-gradient(180deg, rgb(230, 255, 0) 0%, rgb(3, 209, 0) 100%)",
]);
const color = useTransform(x, xInput, ["rgb(211, 9, 225)", "rgb(68, 0, 255)", "rgb(3, 209, 0)"]);
const tickPath = useTransform(x, [10, 100], [0, 1]);
const crossPathA = useTransform(x, [-10, -55], [0, 1]);
const crossPathB = useTransform(x, [-50, -100], [0, 1]);
</script>
<div class="example">
<motion.div class="container" style={{ background }}>
<motion.div class="box" style={{ x }} drag="x" dragConstraints={{ left: 0, right: 0 }} dragElastic={0.5}>
<svg class="progress-icon" viewBox="0 0 50 50">
<motion.path
fill="none"
stroke-width="2"
stroke={color}
d="M 0, 20 a 20, 20 0 1,0 40,0 a 20, 20 0 1,0 -40,0"
style={{ x: 5, y: 5 }}
/>
<motion.path
fill="none"
stroke-width="2"
stroke={color}
d="M14,26 L 22,33 L 35,16"
stroke-dasharray="0 1"
style={{ pathLength: tickPath }}
/>
<motion.path
fill="none"
stroke-width="2"
stroke={color}
d="M17,17 L33,33"
stroke-dasharray="0 1"
style={{ pathLength: crossPathA }}
/>
<motion.path
fill="none"
stroke-width="2"
stroke={color}
d="M33,17 L17,33"
stroke-dasharray="0 1"
style={{ pathLength: crossPathB }}
/>
</svg>
</motion.div>
</motion.div>
</div>
<style>
.example {
width: 100%;
}
.example :global(.box) {
width: 140px;
height: 140px;
padding: 20px;
border-radius: 20px;
background-color: #f5f5f5;
}
.example :global(.container) {
display: flex;
justify-content: center;
align-items: center;
width: 500px;
max-width: 100%;
height: 300px;
margin: 0 auto;
border-radius: 20px;
}
.progress-icon {
width: 100%;
height: 100%;
}
</style> With clamp: false, the ranges keep mapping beyond their ends. For instance, this says "for every 100px scrolled, rotate another 360deg":
const { scrollY } = useScroll();
const rotate = useTransform(scrollY, [0, 100], [0, 360], { clamp: false }); Output multiple values
It's common to map one motion value and input range into several motion values:
const opacity = useTransform(offset, [100, 600], [1, 0.4]);
const scale = useTransform(offset, [100, 600], [1, 0.6]);
const filter = useTransform(offset, [100, 600], ["blur(0px)", "blur(10px)"]); To avoid the repetition, useTransform can also map to several motion values in one call. Pass an object of output ranges, and it returns an object of motion values with the same keys:
const { opacity, scale, filter } = useTransform(offset, [100, 600], {
opacity: [1, 0.4],
scale: [1, 0.6],
filter: ["blur(0px)", "blur(10px)"],
}); Changing the input range
An input range array is fixed once useTransform has been called. If it depends on something that changes, like an element's measured size, wrap a getter for it with reactiveInputRange. The mapping then updates whenever the Svelte state the getter reads changes:
<script lang="ts">
import { reactiveInputRange, useMotionValue, useTransform } from "motion-sv";
let width = $state(0);
const x = useMotionValue(0);
const opacity = useTransform(x, reactiveInputRange(() => [0, width]), [1, 0]);
</script>
<div bind:clientWidth={width}>
<!-- ... -->
</div> Options
Value mapping accepts some additional options as its fourth argument.
clamp
Default: true
If true, the output is clamped to the output range. If false, it keeps mapping when the input goes beyond the input range.
const x = useMotionValue(0);
const y = useTransform(x, [0, 1], [0, 2]);
const z = useTransform(x, [0, 1], [0, 2], { clamp: false });
x.set(2);
// On the next frame: y is 2, because the input is clamped, and z is 4 ease
An easing function, or an array of easing functions, to ease the mixing between each pair of values. With an array, it needs one easing function fewer than the ranges have values, as each one applies to the transition between two values.
These must be JavaScript functions, like the easing functions exported by motion-sv:
import { circOut, cubicBezier, useTransform } from "motion-sv";
const y = useTransform(x, [0, 1], [0, 2], { ease: circOut });
const z = useTransform(x, [0, 1], [0, 2], { ease: cubicBezier(0.17, 0.67, 0.83, 0.67) }); mixer
A function to mix between each pair of output values.
It's called with each pair of output values, and must return a function that takes a progress value between 0 and 1 and returns the mixed value:
const mixer = (from: string, to: string) => (progress: number) => (progress < 0.5 ? from : to);
const label = useTransform(x, [0, 100], ["start", "end"], { mixer }); This lets you plug in more advanced mixers than Motion's own, for instance Flubber for morphing between SVG paths.
Here, a progress motion value animates from one shape's index to the next, and Flubber's interpolate mixes each pair of paths:
<script lang="ts">
import * as flubber from "flubber";
import { animate, motion, useMotionValue, useTransform, type MotionValue } from "motion-sv";
// Paths taken from https://github.com/veltman/flubber/blob/master/demos/basic-svg.html
const star = "M12 17.27L18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z";
const heart =
"M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z";
const hand =
"M23 5.5V20c0 2.2-1.8 4-4 4h-7.3c-1.08 0-2.1-.43-2.85-1.19L1 14.83s1.26-1.23 1.3-1.25c.22-.19.49-.29.79-.29.22 0 .42.06.6.16.04.01 4.31 2.46 4.31 2.46V4c0-.83.67-1.5 1.5-1.5S11 3.17 11 4v7h1V1.5c0-.83.67-1.5 1.5-1.5S15 .67 15 1.5V11h1V2.5c0-.83.67-1.5 1.5-1.5s1.5.67 1.5 1.5V11h1V5.5c0-.83.67-1.5 1.5-1.5s1.5.67 1.5 1.5z";
const plane =
"M21 16v-2l-8-5V3.5c0-.83-.67-1.5-1.5-1.5S10 2.67 10 3.5V9l-8 5v2l8-2.5V19l-2 1.5V22l3.5-1 3.5 1v-1.5L13 19v-5.5l8 2.5z";
const lightning = "M7 2v11h3v9l7-12h-4l4-8z";
const note = "M12 3v10.55c-.59-.34-1.27-.55-2-.55-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4V7h4V3h-6z";
const paths = [lightning, hand, plane, heart, note, star, lightning];
const colors = ["#fff312", "#ff0088", "#dd00ee", "#9911ff", "#0d63f8", "#0cdcf7", "#4ff0b7"];
const getIndex = (_: string, index: number) => index;
function useFlubber(progress: MotionValue<number>, paths: string[]) {
return useTransform(progress, paths.map(getIndex), paths, {
mixer: (a, b) => flubber.interpolate(a, b, { maxSegmentLength: 0.1 }),
});
}
let pathIndex = $state(0);
const progress = useMotionValue(0);
const fill = useTransform(progress, paths.map(getIndex), colors);
const path = useFlubber(progress, paths);
$effect(() => {
const animation = animate(progress, pathIndex, {
duration: 0.8,
ease: "easeInOut",
delay: 0.5,
onComplete: () => {
if (pathIndex === paths.length - 1) {
progress.set(0);
pathIndex = 1;
} else {
pathIndex += 1;
}
},
});
return () => animation.stop();
});
</script>
<svg width="400" height="400">
<g transform="translate(10 10) scale(17 17)">
<motion.path {fill} d={path} />
</g>
</svg>