AnimatePresence
AnimatePresence enables exit animations for components when they are removed from the DOM. Wrap your conditional content with AnimatePresence to animate children as they enter and exit.
<script>
import { motion, AnimatePresence } from '@humanspeak/svelte-motion'
let isVisible = $state(true)
</script>
<AnimatePresence>
{#if isVisible}
<motion.div
key="box"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
/>
{/if}
</AnimatePresence><script>
import { motion, AnimatePresence } from '@humanspeak/svelte-motion'
let isVisible = $state(true)
</script>
<AnimatePresence>
{#if isVisible}
<motion.div
key="box"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
/>
{/if}
</AnimatePresence>Existing conditional form: clone exits
The existing {#if} API remains supported:
<AnimatePresence>
{#if visible}
<motion.div key="card" exit={{ opacity: 0 }} />
{/if}
</AnimatePresence><AnimatePresence>
{#if visible}
<motion.div key="card" exit={{ opacity: 0 }} />
{/if}
</AnimatePresence>When the condition becomes false:
- The component’s last position and styles are captured
- A visual clone is created at that exact position
- The
exitanimation runs on the clone - Once complete, the clone is removed from the DOM
This approach ensures smooth exit animations even though the original Svelte component has already unmounted.
Exit clones are non-interactive (inert) and hidden from the accessibility tree. A subtree containing canvas, iframe, video, or audio is removed immediately instead of animating a clone that would lose its live bitmap, browsing context, or playback state.
Owned real-node exits
Pass present and a named child snippet when the exiting element must remain the original DOM node. AnimatePresence owns the conditional rendering, holds the snippet while it exits, and removes it after the nested motion.* component reports completion.
<script lang="ts">
import {
AnimatePresence,
motion
} from '@humanspeak/svelte-motion'
let visible = $state(true)
</script>
<AnimatePresence present={visible}>
{#snippet child()}
<motion.div
animate={{ opacity: 1 }}
exit={{ opacity: 0, scale: 0.9 }}
>
This is the real node during exit.
</motion.div>
{/snippet}
</AnimatePresence><script lang="ts">
import {
AnimatePresence,
motion
} from '@humanspeak/svelte-motion'
let visible = $state(true)
</script>
<AnimatePresence present={visible}>
{#snippet child()}
<motion.div
animate={{ opacity: 1 }}
exit={{ opacity: 0, scale: 0.9 }}
>
This is the real node during exit.
</motion.div>
{/snippet}
</AnimatePresence>real DOM node
AnimatePresence owns this childExit completes before this node is removed.
Use the owned form for stateful elements, focus-sensitive interfaces, reversible exits, and integrations that need a live VisualElement or DOM node throughout the exit. It does not require a key because it owns one child.
| Behavior | {#if visible} | present={visible} |
|---|---|---|
| Exiting DOM node | Detached clone | Original node |
key requirement | Required on a direct motion.* child | Not required for the owned child |
| Rapid re-entry | New node appears while the old ghost exits | The same node reverses its exit |
| Focus, canvas, media, live state | Not retained by the clone | Retained while exit is in progress |
| VisualElement during exit | Disconnected | Live |
PresenceChild remains available as the lower-level primitive for custom CSS, GSAP, canvas, or other manually completed exit work.
Usage
Basic enter/exit
The most common pattern is toggling visibility with enter and exit animations:
<script lang="ts">
import { motion, AnimatePresence } from '@humanspeak/svelte-motion'
let isVisible = $state(true)
</script>
<AnimatePresence>
{#if isVisible}
<motion.div
key="modal"
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
transition={{ type: 'spring', stiffness: 300, damping: 25 }}
>
Modal content
</motion.div>
{/if}
</AnimatePresence>
<button onclick={() => isVisible = !isVisible}>
Toggle
</button><script lang="ts">
import { motion, AnimatePresence } from '@humanspeak/svelte-motion'
let isVisible = $state(true)
</script>
<AnimatePresence>
{#if isVisible}
<motion.div
key="modal"
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
transition={{ type: 'spring', stiffness: 300, damping: 25 }}
>
Modal content
</motion.div>
{/if}
</AnimatePresence>
<button onclick={() => isVisible = !isVisible}>
Toggle
</button>The key prop
When using AnimatePresence, motion components need a key prop to track their identity:
<AnimatePresence>
{#if isVisible}
<motion.div
key="unique-id"
exit={{ opacity: 0 }}
/>
{/if}
</AnimatePresence><AnimatePresence>
{#if isVisible}
<motion.div
key="unique-id"
exit={{ opacity: 0 }}
/>
{/if}
</AnimatePresence>The key is used to:
- Track which components are entering vs. exiting
- Ensure the correct exit animation plays for each component
- Handle re-entry animations correctly
Suppressing initial animation
By default, all children animate when AnimatePresence first mounts. To prevent this, set initial={false}:
<AnimatePresence initial={false}>
{#if isVisible}
<motion.div
key="box"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
/>
{/if}
</AnimatePresence><AnimatePresence initial={false}>
{#if isVisible}
<motion.div
key="box"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
/>
{/if}
</AnimatePresence>With initial={false}:
- Children present on first render skip their enter animation
- Only subsequent entries (after exits) will animate in
Exit completion callback
Use onExitComplete to run code after all exit animations finish:
<script>
function handleExitComplete() {
console.log('All exits complete!')
// Navigate, cleanup, etc.
}
</script>
<AnimatePresence onExitComplete={handleExitComplete}>
{#if isVisible}
<motion.div key="box" exit={{ opacity: 0 }} />
{/if}
</AnimatePresence><script>
function handleExitComplete() {
console.log('All exits complete!')
// Navigate, cleanup, etc.
}
</script>
<AnimatePresence onExitComplete={handleExitComplete}>
{#if isVisible}
<motion.div key="box" exit={{ opacity: 0 }} />
{/if}
</AnimatePresence>This is useful for:
- Navigation after a page transition completes
- Cleanup operations
- Triggering subsequent animations
Working with lists
AnimatePresence works with dynamic lists using Svelte’s {#each} block:
<script lang="ts">
import { motion, AnimatePresence } from '@humanspeak/svelte-motion'
let items = $state([
{ id: 1, text: 'Item 1' },
{ id: 2, text: 'Item 2' },
{ id: 3, text: 'Item 3' }
])
function removeItem(id: number) {
items = items.filter(item => item.id !== id)
}
</script>
<AnimatePresence>
{#each items as item (item.id)}
<motion.div
key={String(item.id)}
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 20 }}
>
{item.text}
<button onclick={() => removeItem(item.id)}>Remove</button>
</motion.div>
{/each}
</AnimatePresence><script lang="ts">
import { motion, AnimatePresence } from '@humanspeak/svelte-motion'
let items = $state([
{ id: 1, text: 'Item 1' },
{ id: 2, text: 'Item 2' },
{ id: 3, text: 'Item 3' }
])
function removeItem(id: number) {
items = items.filter(item => item.id !== id)
}
</script>
<AnimatePresence>
{#each items as item (item.id)}
<motion.div
key={String(item.id)}
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 20 }}
>
{item.text}
<button onclick={() => removeItem(item.id)}>Remove</button>
</motion.div>
{/each}
</AnimatePresence>Important: Use Svelte’s keyed
{#each}block ({#each items as item (item.id)}) and pass a matching stringkeyprop to the motion component.
Combining with variants
AnimatePresence works seamlessly with variants for cleaner, reusable animations:
<script lang="ts">
import { motion, AnimatePresence, type Variants } from '@humanspeak/svelte-motion'
let isVisible = $state(true)
const itemVariants: Variants = {
hidden: { opacity: 0, y: 20 },
visible: { opacity: 1, y: 0 },
exit: { opacity: 0, y: -20 }
}
</script>
<AnimatePresence>
{#if isVisible}
<motion.div
key="item"
variants={itemVariants}
initial="hidden"
animate="visible"
exit="exit"
/>
{/if}
</AnimatePresence><script lang="ts">
import { motion, AnimatePresence, type Variants } from '@humanspeak/svelte-motion'
let isVisible = $state(true)
const itemVariants: Variants = {
hidden: { opacity: 0, y: 20 },
visible: { opacity: 1, y: 0 },
exit: { opacity: 0, y: -20 }
}
</script>
<AnimatePresence>
{#if isVisible}
<motion.div
key="item"
variants={itemVariants}
initial="hidden"
animate="visible"
exit="exit"
/>
{/if}
</AnimatePresence>Exit transition configuration
You can specify a custom transition for the exit animation:
<motion.div
key="box"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{
opacity: 0,
scale: 0.8,
transition: { duration: 0.2, ease: 'easeIn' }
}}
/><motion.div
key="box"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{
opacity: 0,
scale: 0.8,
transition: { duration: 0.2, ease: 'easeIn' }
}}
/>The exit transition takes precedence over the component’s general transition prop.
Props
AnimatePresence
| Prop | Type | Default | Description |
|---|---|---|---|
initial | boolean | true | When false, children skip their enter animation on initial mount |
onExitComplete | () => void | undefined | Callback invoked once all exit animations complete |
Motion component props (for AnimatePresence)
| Prop | Type | Description |
|---|---|---|
key | string | Required. Unique identifier for tracking enter/exit state |
initial | object \| string \| false | Initial animation state |
animate | object \| string | Target animation state |
exit | object \| string | Exit animation state when component is removed |
Best practices
1. Always provide a key
Every motion component inside AnimatePresence should have a unique key:
<!-- Good -->
<motion.div key="modal" exit={{ opacity: 0 }} />
<!-- Bad - no key -->
<motion.div exit={{ opacity: 0 }} /><!-- Good -->
<motion.div key="modal" exit={{ opacity: 0 }} />
<!-- Bad - no key -->
<motion.div exit={{ opacity: 0 }} />2. Use semantic exit animations
Exit animations should feel natural and match the context:
<!-- Modal: scale down and fade -->
<motion.div
key="modal"
exit={{ opacity: 0, scale: 0.95 }}
/>
<!-- Sidebar: slide out -->
<motion.div
key="sidebar"
exit={{ x: -300 }}
/>
<!-- Toast: slide up and fade -->
<motion.div
key="toast"
exit={{ opacity: 0, y: -20 }}
/><!-- Modal: scale down and fade -->
<motion.div
key="modal"
exit={{ opacity: 0, scale: 0.95 }}
/>
<!-- Sidebar: slide out -->
<motion.div
key="sidebar"
exit={{ x: -300 }}
/>
<!-- Toast: slide up and fade -->
<motion.div
key="toast"
exit={{ opacity: 0, y: -20 }}
/>3. Match enter and exit
For visual consistency, exit animations often mirror enter animations:
<motion.div
key="item"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 20 }} <!-- Same as initial -->
/><motion.div
key="item"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 20 }} <!-- Same as initial -->
/>4. Consider performance
Keep exit animations short (200-400ms) to avoid UI feeling sluggish:
<motion.div
key="item"
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
/><motion.div
key="item"
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
/>Common patterns
Page transitions
<script>
import { page } from '$app/stores'
</script>
<AnimatePresence>
{#key $page.url.pathname}
<motion.main
key={$page.url.pathname}
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
>
<slot />
</motion.main>
{/key}
</AnimatePresence><script>
import { page } from '$app/stores'
</script>
<AnimatePresence>
{#key $page.url.pathname}
<motion.main
key={$page.url.pathname}
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
>
<slot />
</motion.main>
{/key}
</AnimatePresence>Modal/Dialog
<AnimatePresence>
{#if showModal}
<!-- Backdrop -->
<motion.div
key="backdrop"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
class="backdrop"
onclick={() => showModal = false}
/>
<!-- Modal -->
<motion.div
key="modal"
initial={{ opacity: 0, scale: 0.9, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.9, y: 20 }}
class="modal"
>
Modal content
</motion.div>
{/if}
</AnimatePresence><AnimatePresence>
{#if showModal}
<!-- Backdrop -->
<motion.div
key="backdrop"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
class="backdrop"
onclick={() => showModal = false}
/>
<!-- Modal -->
<motion.div
key="modal"
initial={{ opacity: 0, scale: 0.9, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.9, y: 20 }}
class="modal"
>
Modal content
</motion.div>
{/if}
</AnimatePresence>Notification stack
<AnimatePresence>
{#each notifications as notification (notification.id)}
<motion.div
key={notification.id}
initial={{ opacity: 0, x: 100 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 100 }}
transition={{ type: 'spring', stiffness: 500, damping: 30 }}
>
{notification.message}
</motion.div>
{/each}
</AnimatePresence><AnimatePresence>
{#each notifications as notification (notification.id)}
<motion.div
key={notification.id}
initial={{ opacity: 0, x: 100 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 100 }}
transition={{ type: 'spring', stiffness: 500, damping: 30 }}
>
{notification.message}
</motion.div>
{/each}
</AnimatePresence>Related
- Variants - Define reusable animation states
- Examples - See AnimatePresence in action
- Motion Component - Full API reference
Based on Motion’s AnimatePresence API.