AnimatePresence
Animate elements out before Svelte removes them from the page.
AnimatePresence makes exit animations easy. Svelte removes an element from the DOM as soon as the block that renders it goes away. Wrap the block in AnimatePresence, and its motion elements stay on the page while their exit animation plays.
<AnimatePresence>
{#if isVisible}
<motion.div exit={{ opacity: 0 }} />
{/if}
</AnimatePresence> <script lang="ts">
import { AnimatePresence, motion } from "motion-sv";
let isVisible = $state(true);
</script>
<div class="container">
<AnimatePresence initial={false}>
{#if isVisible}
<motion.div
class="box"
initial={{ opacity: 0, scale: 0 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0 }}
/>
{/if}
</AnimatePresence>
<motion.button class="button" onclick={() => (isVisible = !isVisible)} whilePress={{ y: 1 }}>
{isVisible ? "Hide" : "Show"}
</motion.button>
</div>
<style>
.container {
display: flex;
flex-direction: column;
width: 100px;
height: 160px;
position: relative;
}
.container :global(.box) {
width: 100px;
height: 100px;
background-color: #0cdcf7;
border-radius: 10px;
}
.container :global(.button) {
background-color: #0cdcf7;
border: none;
border-radius: 10px;
padding: 10px 20px;
color: #0b1012;
font: inherit;
font-size: 15px;
font-weight: 540;
line-height: 1;
cursor: pointer;
position: absolute;
bottom: 0;
left: 0;
right: 0;
}
</style> Usage
Import
import { AnimatePresence } from "motion-sv"; Exit animations
AnimatePresence works with Svelte's blocks. Its motion elements animate out when a block inside it removes them. That can be an {#if} block whose condition turns false:
<AnimatePresence>
{#if isOpen}
<motion.div exit={{ opacity: 0 }} />
{/if}
</AnimatePresence> A {#key} block whose key changes:
<AnimatePresence>
{#key image.src}
<motion.img src={image.src} exit={{ opacity: 0 }} />
{/key}
</AnimatePresence> Or a keyed {#each} block whose item is removed:
<AnimatePresence>
{#each items as item (item.id)}
<motion.li exit={{ opacity: 0 }}>{item.label}</motion.li>
{/each}
</AnimatePresence> The motion elements don't need to be direct children of the block. They can be nested inside other elements, or inside components:
<AnimatePresence>
{#if isOpen}
<!-- Modal renders a <motion.div exit={{ opacity: 0 }}> -->
<Modal />
{/if}
</AnimatePresence> Every motion element in the removed block plays the animation set by its own exit prop, and the block leaves the DOM once they've all finished:
<motion.div exit={{ opacity: 0 }}>
<img src={image.src} alt="" />
<motion.p exit={{ y: 10 }}>{description}</motion.p>
</motion.div> Like initial and animate, exit can be a target or a variant label. Variants flow down to children, so a parent can exit its children first with when: "afterChildren", and stagger them with delayChildren:
<script lang="ts">
import { AnimatePresence, motion, stagger, type Variants } from "motion-sv";
const list: Variants = {
visible: { opacity: 1, transition: { when: "beforeChildren", delayChildren: stagger(0.06) } },
hidden: { opacity: 0, transition: { when: "afterChildren", delayChildren: stagger(0.04, { from: "last" }) } },
};
const item: Variants = {
visible: { opacity: 1, x: 0 },
hidden: { opacity: 0, x: -24 },
};
</script>
<AnimatePresence>
{#if isOpen}
<motion.ul variants={list} initial="hidden" animate="visible" exit="hidden">
{#each items as label (label)}
<motion.li variants={item}>{label}</motion.li>
{/each}
</motion.ul>
{/if}
</AnimatePresence> <script lang="ts">
import { AnimatePresence, motion, stagger, type Variants } from "motion-sv";
const items = ["Inbox", "Drafts", "Sent", "Archive"];
let isOpen = $state(true);
const list: Variants = {
visible: { opacity: 1, transition: { when: "beforeChildren", delayChildren: stagger(0.06) } },
hidden: { opacity: 0, transition: { when: "afterChildren", delayChildren: stagger(0.04, { from: "last" }) } },
};
const item: Variants = {
visible: { opacity: 1, x: 0 },
hidden: { opacity: 0, x: -24 },
};
</script>
<div class="stage">
<button onclick={() => (isOpen = !isOpen)}>{isOpen ? "Close menu" : "Open menu"}</button>
<div class="slot">
<AnimatePresence>
{#if isOpen}
<motion.ul class="menu" variants={list} initial="hidden" animate="visible" exit="hidden">
{#each items as label (label)}
<motion.li variants={item}>{label}</motion.li>
{/each}
</motion.ul>
{/if}
</AnimatePresence>
</div>
</div>
<style>
.stage {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
}
.slot {
width: 200px;
height: 200px;
}
.stage :global(.menu) {
display: flex;
flex-direction: column;
gap: 6px;
margin: 0;
padding: 8px;
border-radius: 14px;
background: rgb(12 220 247 / 0.15);
list-style: none;
}
.stage :global(li) {
margin: 0;
padding: 8px 12px;
border-radius: 8px;
background: rgb(12 220 247 / 0.3);
}
button {
padding: 8px 20px;
border: none;
border-radius: 8px;
background: #0cdcf7;
color: #0f172a;
font: inherit;
font-weight: 600;
cursor: pointer;
}
</style> Changing key
Where React replaces an element by changing its key, Svelte has the {#key} block: when its expression changes, Svelte destroys the block's content and renders it again. Inside AnimatePresence, the old content plays its exit animation while the new content enters, which is all a slideshow needs:
<script lang="ts">
import { AnimatePresence, motion } from "motion-sv";
const colors = ["#ff3e00", "#9911ff", "#0cdcf7", "#ff0088"];
let index = $state(0);
</script>
<AnimatePresence initial={false}>
{#key index}
<motion.div
class="slide"
style={{ background: colors[index] }}
initial={{ x: 300, opacity: 0 }}
animate={{ x: 0, opacity: 1 }}
exit={{ x: -300, opacity: 0 }}
/>
{/key}
</AnimatePresence>
<button onclick={() => (index = (index + 1) % colors.length)}>Next</button> <script lang="ts">
import { AnimatePresence, motion } from "motion-sv";
const colors = ["#ff3e00", "#9911ff", "#0cdcf7", "#ff0088"];
let index = $state(0);
</script>
<div class="slideshow">
<div class="viewport">
<AnimatePresence initial={false}>
{#key index}
<motion.div
class="slide"
style={{ background: colors[index] }}
initial={{ x: 300, opacity: 0 }}
animate={{ x: 0, opacity: 1 }}
exit={{ x: -300, opacity: 0 }}
transition={{ type: "spring", visualDuration: 0.4, bounce: 0.2 }}
>
{index + 1}
</motion.div>
{/key}
</AnimatePresence>
</div>
<button onclick={() => (index = (index + 1) % colors.length)}>Next</button>
</div>
<style>
.slideshow {
display: flex;
flex-direction: column;
align-items: center;
gap: 1.5rem;
}
.viewport {
position: relative;
width: 240px;
height: 140px;
}
.slideshow :global(.slide) {
position: absolute;
inset: 0;
display: grid;
place-items: center;
border-radius: 20px;
color: white;
font-size: 48px;
font-weight: 700;
}
button {
padding: 8px 20px;
border: none;
border-radius: 8px;
background: #ff3e00;
color: white;
font: inherit;
cursor: pointer;
}
</style> An exiting block no longer updates, so the old slide keeps its color while it leaves. Both slides are on the page while they swap, so the slides here use position: absolute to overlap. To show one at a time instead, use mode="wait".
Props
initial
By passing initial={false}, AnimatePresence disables the enter animation of the children that are present when it first renders. Children added later still animate in.
<AnimatePresence initial={false}>
{#key slide.id}
<Slide {slide} />
{/key}
</AnimatePresence> custom
Once a block is removed, it no longer updates, so an exiting element's props can't change anymore. Its exit animation can't depend on state that changes in the same update that removed it.
Pass that state to AnimatePresence's custom prop instead. Exiting elements use its latest value to resolve dynamic variants, functions that return a target. Give the entering element the same value with its own custom prop.
Here, the slideshow uses the direction of the last click to decide which way each slide enters and leaves:
<script lang="ts">
import { AnimatePresence, motion, type Variants } from "motion-sv";
let index = $state(0);
let direction = $state(1);
const variants: Variants = {
enter: (direction: number) => ({ x: direction * 300, opacity: 0 }),
center: { x: 0, opacity: 1 },
exit: (direction: number) => ({ x: direction * -300, opacity: 0 }),
};
function paginate(newDirection: number) {
direction = newDirection;
index += newDirection;
}
</script>
<AnimatePresence initial={false} custom={direction}>
{#key index}
<motion.div
class="slide"
custom={direction}
{variants}
initial="enter"
animate="center"
exit="exit"
/>
{/key}
</AnimatePresence> <script lang="ts">
import { AnimatePresence, motion, type Variants } from "motion-sv";
const colors = ["#ff3e00", "#9911ff", "#0cdcf7", "#ff0088"];
let index = $state(0);
let direction = $state(1);
const variants: Variants = {
enter: (direction: number) => ({ x: direction * 300, opacity: 0 }),
center: { x: 0, opacity: 1 },
exit: (direction: number) => ({ x: direction * -300, opacity: 0 }),
};
function paginate(newDirection: number) {
direction = newDirection;
index = (index + newDirection + colors.length) % colors.length;
}
</script>
<div class="slideshow">
<div class="viewport">
<AnimatePresence initial={false} custom={direction}>
{#key index}
<motion.div
class="slide"
style={{ background: colors[index] }}
custom={direction}
{variants}
initial="enter"
animate="center"
exit="exit"
transition={{ type: "spring", visualDuration: 0.4, bounce: 0.2 }}
>
{index + 1}
</motion.div>
{/key}
</AnimatePresence>
</div>
<div class="controls">
<button onclick={() => paginate(-1)} aria-label="Previous">←</button>
<button onclick={() => paginate(1)} aria-label="Next">→</button>
</div>
</div>
<style>
.slideshow {
display: flex;
flex-direction: column;
align-items: center;
gap: 1.5rem;
}
.viewport {
position: relative;
width: 240px;
height: 140px;
}
.slideshow :global(.slide) {
position: absolute;
inset: 0;
display: grid;
place-items: center;
border-radius: 20px;
color: white;
font-size: 48px;
font-weight: 700;
}
.controls {
display: flex;
gap: 0.5rem;
}
button {
width: 44px;
height: 36px;
border: none;
border-radius: 8px;
background: #ff3e00;
color: white;
font: inherit;
cursor: pointer;
}
</style> mode
Default: "sync"
Decides how AnimatePresence handles entering and exiting children:
"sync": Children animate in and out as soon as they're added and removed. Entering and exiting elements are on the page at the same time."wait": Entering children wait until the exiting children have animated out."popLayout": Exiting children are popped out of the page layout, so the elements around them can move to their new layout immediately.
wait
With mode="wait", only one child is visible at a time. Here, each status animates out before the next one animates in:
<AnimatePresence mode="wait">
{#key index}
<motion.div
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -16 }}
>
{steps[index]}
</motion.div>
{/key}
</AnimatePresence> <script lang="ts">
import { AnimatePresence, motion } from "motion-sv";
const steps = [
{ label: "Ordered", color: "#ff3e00" },
{ label: "Shipped", color: "#9911ff" },
{ label: "Delivered", color: "#0cdcf7" },
];
let index = $state(0);
const step = $derived(steps[index]);
</script>
<div class="stage">
<AnimatePresence mode="wait">
{#key index}
<motion.div
class="status"
style={{ background: step.color }}
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -16 }}
transition={{ duration: 0.2 }}
>
{step.label}
</motion.div>
{/key}
</AnimatePresence>
<button onclick={() => (index = (index + 1) % steps.length)}>Next step</button>
</div>
<style>
.stage {
display: flex;
flex-direction: column;
align-items: center;
gap: 1.5rem;
}
.stage :global(.status) {
padding: 12px 28px;
border-radius: 999px;
color: white;
font-size: 20px;
font-weight: 600;
}
button {
padding: 8px 20px;
border: 1px solid currentColor;
border-radius: 8px;
background: none;
color: inherit;
font: inherit;
cursor: pointer;
}
</style> Svelte renders the new block straight away, so the entering motion elements wait on the page, hidden with display: none, until the exits have finished. Content outside of a motion element doesn't wait, so wrap the whole block's content in one.
popLayout
With mode="popLayout", an exiting element is taken out of the page layout with position: absolute, at the position and size it had. Pair it with layout animations, and the elements around it move into its space right away, while it animates out.
Render the other elements through a layout namespace. Before an element exits, AnimatePresence measures the layout elements inside it, so the namespace doesn't need a dependency for removals. Click a square to remove it:
<script lang="ts">
import { AnimatePresence, createLayoutMotion } from "motion-sv";
const layout = createLayoutMotion();
let items = $state([0, 1, 2, 3, 4]);
const remove = (id: number) => (items = items.filter((item) => item !== id));
</script>
<ul style="position: relative">
<AnimatePresence mode="popLayout">
{#each items as id (id)}
<layout.li
initial={{ opacity: 0, scale: 0.6 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.6 }}
onclick={() => remove(id)}
/>
{/each}
</AnimatePresence>
</ul> <script lang="ts">
import { AnimatePresence, createLayoutMotion } from "motion-sv";
const colors = ["#ff3e00", "#9911ff", "#0cdcf7", "#8df0cc", "#ff0088"];
let nextId = 5;
let items = $state([0, 1, 2, 3, 4]);
const layout = createLayoutMotion(() => items);
const add = () => (items = [...items, nextId++]);
const remove = (id: number) => (items = items.filter((item) => item !== id));
</script>
<div class="stage">
<ul class="row">
<AnimatePresence mode="popLayout" initial={false}>
{#each items as id (id)}
<layout.li
class="chip"
style={{ background: colors[id % colors.length] }}
initial={{ opacity: 0, scale: 0.6 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.6 }}
transition={{ type: "spring", visualDuration: 0.3, bounce: 0.2 }}
onclick={() => remove(id)}
/>
{/each}
</AnimatePresence>
</ul>
<button onclick={add}>Add</button>
</div>
<style>
.stage {
display: flex;
flex-direction: column;
align-items: center;
gap: 1.5rem;
}
.row {
position: relative;
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 12px;
width: 380px;
min-height: 64px;
margin: 0;
padding: 0;
list-style: none;
}
.row :global(.chip) {
width: 64px;
height: 64px;
margin: 0;
border-radius: 16px;
cursor: pointer;
}
button {
padding: 8px 20px;
border: none;
border-radius: 8px;
background: #ff0088;
color: white;
font: inherit;
cursor: pointer;
}
</style> Adding an element moves the others without an exit, so the demo's namespace also depends on the list, as with any layout change.
The popped element is positioned relative to its offset parent, so give the element around it a position other than static, like the list's position: relative here.
anchorX
Default: "left"
When mode is "popLayout", anchorX decides which edge of its offset parent an exiting element is pinned to: "left" keeps its distance to the left edge, "right" to the right edge. Use "right" for right-aligned content, so it doesn't shift if its offset parent's width changes. In right-to-left layouts, the two edges are swapped.
<AnimatePresence mode="popLayout" anchorX="right">
<!-- ... -->
</AnimatePresence> onExitComplete
Fires when all exiting children have finished animating out.
<AnimatePresence onExitComplete={() => console.log("All exits complete")}>
<!-- ... -->
</AnimatePresence> Troubleshooting
Exit animations aren't working
Make sure the element has an exit prop. Elements without one leave the page straight away. So do components made with motion.create(): wrap them in a motion element with the exit animation.
In {#each} blocks, give every item a key that's unique and stays the same for that item, like an ID. The index is a bad key: when items are removed or reordered, Svelte matches indexes rather than items, and the wrong element animates out.
<!-- Don't -->
{#each items as item, index (index)}
<!-- Do -->
{#each items as item (item.id)} Also make sure AnimatePresence itself stays on the page. If it's removed, it can't hold its children, and they leave without their exit animations. For example, this won't work:
{#if isVisible}
<AnimatePresence>
<motion.div exit={{ opacity: 0 }} />
</AnimatePresence>
{/if} Instead, put the condition inside AnimatePresence:
<AnimatePresence>
{#if isVisible}
<motion.div exit={{ opacity: 0 }} />
{/if}
</AnimatePresence> The elements around an exiting element jump
Only layout elements animate to a new layout. Render the siblings that move through a layout namespace: before an element exits, AnimatePresence measures the layout elements inside it.
If elements outside it move too, like a button below the list, render them through a layout namespace as well, and wrap them and the AnimatePresence in a LayoutGroup, which measures the whole group together.
With the default mode="sync", the exiting element stays in the layout until its exit animation finishes, and the elements around it move once it's gone. Use mode="popLayout" to move them right away.
If the same state change also adds elements or changes other layouts, give the namespace a dependency on that state, as with any layout change:
<script lang="ts">
let items = $state([{ id: 1 }, { id: 2 }]);
// AnimatePresence measures removals, the dependency covers additions
const layout = createLayoutMotion(() => items);
</script> popLayout elements appear in the wrong place
mode="popLayout" positions exiting elements with position: absolute, relative to their offset parent. Give the element around them a position other than static.
An ancestor with a transform, for instance during its own layout animation, positions its absolutely positioned descendants even when its position is static. So if the element around the AnimatePresence animates its layout, give it position: relative too, so that exiting elements are measured and positioned relative to the same element:
<layout.ul style={{ position: "relative" }}>
<AnimatePresence mode="popLayout">
{#each items as item (item.id)}
<layout.li exit={{ opacity: 0 }} />
{/each}
</AnimatePresence>
</layout.ul>