useAnimate
A version of Motion's animate function that's scoped to your component, and stops its animations when the component unmounts.
useAnimate gives you a version of Motion's animate function that's scoped to the elements of your component.
It's for animations you want to control by hand: timelines, playback controls, selectors that only match inside your component, and animations that clean themselves up.
It returns a scope and an animate function. Every selector you pass to animate only matches elements inside scope.current:
<script lang="ts">
import { useAnimate } from "motion-sv";
const [scope, animate] = useAnimate();
$effect(() => {
// This "li" selector only matches children of the <ul>
animate("li", { opacity: 1 });
});
</script>
<ul bind:this={scope.current}>
<li>One</li>
<li>Two</li>
</ul> When the component unmounts, every animation started with its animate function stops.
Usage
Import useAnimate from motion-sv:
import { useAnimate } from "motion-sv"; useAnimate returns two values: a scope and an animate function.
<script lang="ts">
import { useAnimate } from "motion-sv";
const [scope, animate] = useAnimate();
</script> Bind scope.current to a regular HTML or SVG element with bind:this:
<ul bind:this={scope.current}>
<!-- ... -->
</ul> Or to a motion component with bind:ref:
<script lang="ts">
import { motion, useAnimate } from "motion-sv";
const [scope, animate] = useAnimate();
</script>
<motion.ul bind:ref={scope.current}>
<!-- ... -->
</motion.ul> scope.current is an HTMLElement | SVGElement. To narrow it, pass the element type, like useAnimate<HTMLUListElement>().
Now you can use the scoped animate function to animate the scope element directly:
animate(scope.current, { opacity: 1 }, { duration: 1 }); Or elements inside it, with a selector:
animate("li", { backgroundColor: "#000" }, { ease: "linear" }); The "li" selector doesn't match every li on the page, only the ones inside the scope element.
<script lang="ts">
import { stagger, useAnimate } from "motion-sv";
const [scope, animate] = useAnimate<HTMLUListElement>();
// Runs once, after the list mounts
$effect(() => {
animate("li", { opacity: 1, x: 0 }, { delay: stagger(0.1) });
});
</script>
<ul bind:this={scope.current}>
<li>Scoped</li>
<li>Selectors</li>
<li>Stagger</li>
<li>Cleanup</li>
</ul>
<style>
ul {
display: flex;
flex-direction: column;
gap: 8px;
width: 220px;
margin: 0;
padding: 0;
list-style: none;
}
li {
padding: 10px 16px;
border-radius: 10px;
background: rgb(255 62 0 / 0.15);
opacity: 0;
transform: translateX(-40px);
}
</style> When to call animate
Call animate from event handlers, or from an effect once the scope is bound.
<button onclick={() => animate(scope.current, { rotate: 360 })}>Spin</button> An $effect re-runs whenever state it reads changes. That's useful when an animation should follow your state:
<script lang="ts">
let isOpen = $state(false);
$effect(() => {
animate(scope.current, { opacity: isOpen ? 1 : 0, y: isOpen ? 0 : 20 });
});
</script> The effect re-runs when the state it reads changes, here isOpen. animate doesn't track the state it reads itself, like scope.current and the nearest MotionConfig's settings, so an effect that reads no other state runs once, when the component mounts:
<script lang="ts">
$effect(() => {
animate("li", { opacity: 1, x: 0 }, { delay: stagger(0.1) });
});
</script> Sequences
Pass animate an array of segments to play a timeline sequence. Selectors in each segment are scoped too.
function play() {
animate([
[".fill", { scaleX: [0, 1] }, { duration: 0.8 }],
[".dot", { scale: [0, 1] }, { delay: stagger(0.1), type: "spring" }],
[".label", { opacity: [0, 1] }, { at: "-0.2" }],
]);
} <script lang="ts">
import { stagger, useAnimate } from "motion-sv";
const [scope, animate] = useAnimate<HTMLDivElement>();
function play() {
animate([
[".fill", { scaleX: [0, 1] }, { duration: 0.8, ease: "easeInOut" }],
[".dot", { scale: [0, 1] }, { delay: stagger(0.1), type: "spring", bounce: 0.5 }],
[".label", { opacity: [0, 1], y: [8, 0] }, { at: "-0.2" }],
]);
}
</script>
<div class="sequence" bind:this={scope.current}>
<div class="track"><div class="fill"></div></div>
<div class="dots">
<span class="dot"></span>
<span class="dot"></span>
<span class="dot"></span>
</div>
<p class="label">Done!</p>
<button onclick={play}>Play sequence</button>
</div>
<style>
.sequence {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
width: 240px;
}
.track {
width: 100%;
height: 8px;
border-radius: 4px;
background: rgb(153 17 255 / 0.15);
overflow: hidden;
}
.fill {
height: 100%;
background: #9911ff;
transform: scaleX(0);
transform-origin: left;
}
.dots {
display: flex;
gap: 12px;
}
.dot {
width: 24px;
height: 24px;
border-radius: 50%;
background: #9911ff;
transform: scale(0);
}
.label {
margin: 0;
font-weight: 600;
opacity: 0;
}
button {
padding: 8px 16px;
border: none;
border-radius: 8px;
background: #9911ff;
color: white;
font: inherit;
cursor: pointer;
}
</style> Playback controls
animate returns animation controls. You can pause and resume the animation, change its speed, seek with time, or wait for it to finish:
const animation = animate(".ball", { x: 100 }, { repeat: Infinity, repeatType: "reverse" });
animation.pause();
animation.play();
animation.speed = 0.25;
animation.time = 0;
await animation.finished; <script lang="ts">
import { useAnimate, type AnimationPlaybackControls } from "motion-sv";
const [scope, animate] = useAnimate<HTMLDivElement>();
let animation: AnimationPlaybackControls | undefined;
let isPlaying = $state(true);
let isSlow = $state(false);
$effect(() => {
animation = animate(
".ball",
{ x: [-100, 100] },
{ duration: 1, ease: "easeInOut", repeat: Infinity, repeatType: "reverse" }
);
});
function togglePlay() {
isPlaying = !isPlaying;
if (isPlaying) animation?.play();
else animation?.pause();
}
function toggleSpeed() {
isSlow = !isSlow;
if (animation) animation.speed = isSlow ? 0.25 : 1;
}
</script>
<div class="controls" bind:this={scope.current}>
<div class="ball"></div>
<div class="buttons">
<button class="toggle" onclick={togglePlay}>{isPlaying ? "Pause" : "Play"}</button>
<button class="speed" onclick={toggleSpeed}>{isSlow ? "Normal speed" : "Slow motion"}</button>
</div>
</div>
<style>
.controls {
display: flex;
flex-direction: column;
align-items: center;
gap: 32px;
}
.ball {
width: 60px;
height: 60px;
border-radius: 50%;
background: #0cdcf7;
}
.buttons {
display: flex;
gap: 8px;
}
button {
min-width: 120px;
padding: 8px 16px;
border: none;
border-radius: 8px;
background: rgb(12 220 247 / 0.2);
color: inherit;
font: inherit;
cursor: pointer;
}
</style> See animate for everything it can do, including animating motion values, objects and SVG, and every playback control.
Scroll-triggered animations
Combine useAnimate with useInView to animate when the scope scrolls into view:
<script lang="ts">
import { stagger, useAnimate, useInView } from "motion-sv";
const [scope, animate] = useAnimate();
const inView = useInView(
() => scope.current,
() => ({ once: true })
);
$effect(() => {
if (inView.current) {
animate("li", { opacity: 1, y: 0 }, { delay: stagger(0.1) });
}
});
</script>
<ul bind:this={scope.current}>
<li>One</li>
<li>Two</li>
<li>Three</li>
</ul> Cleanup
scope.animations holds every animation started with the scoped animate that hasn't finished yet. When the component unmounts, they're all stopped, so an animation never outlives its component.
Animations started with the animate function imported from motion-sv aren't scoped, and aren't stopped on unmount.
Reduced motion
The scoped animate follows the nearest MotionConfig, like motion components do:
- With
reducedMotion="always", orreducedMotion="user"when the device's reduced motion setting is on, transforms and position and size values, likex,scaleandwidth, jump straight to their final value. Other values, likeopacityand colors, still animate. - With
skipAnimations, every value jumps straight to its final value.