Presence (Enter / Exit)
Animate elements as they're added and removed, like Motion's AnimatePresence.
Overview
Mercury’s presence is the equivalent of Motion’s <AnimatePresence> — without the wrapper component. It’s a Svelte transition, so it goes directly on the element whose mounting and unmounting you want to animate, and Svelte keeps the element in the page until its exit has played.
There are two ways to describe the animation:
- Motion-style:
initial,animateandexiton the element’smercury()attachment, withpresencemarking it as a presence child. This supports everything Motion’sexitdoes — variants,custom, orchestrated children. - Shorthand:
initialandexitonpresenceitself, animating to and from the element’s CSS. No attachment needed.
Motion-style
{#if show}
<div
transition:presence
{@attach mercury({
initial: { opacity: 0, scale: 0.8 },
animate: { opacity: 1, scale: 1 },
exit: { opacity: 0, scale: 0.8 },
transition: { type: 'spring', bounce: 0.2 }
})}
/>
{/if}
This maps one-to-one from Motion:
// motion/react
<AnimatePresence>
{show && (
<motion.div
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
transition={{ type: 'spring', bounce: 0.2 }}
/>
)}
</AnimatePresence>
The exit runs through Motion’s animation state, so it can be a variant, and children with variants exit with it — staggered, or before or after it with when:
<script lang="ts">
const menu: Variants = {
open: { opacity: 1, transition: { staggerChildren: 0.05 } },
closed: { opacity: 0, transition: { when: 'afterChildren', staggerChildren: 0.03 } }
};
const item: Variants = { open: { opacity: 1, x: 0 }, closed: { opacity: 0, x: -16 } };
</script>
{#if open}
<ul
transition:presence
{@attach mercury({ variants: menu, initial: 'closed', animate: 'open', exit: 'closed' })}
>
{#each items as label (label)}
<li {@attach mercury({ variants: item })}>{label}</li>
{/each}
</ul>
{/if}
Svelte removes the element as soon as the whole exit — children included — has finished.
Exit variants and custom
An exiting element no longer receives new props, but its exit may depend on state that changed since — like which way a slideshow is going. As with <AnimatePresence custom>, the custom on presence is passed to the exit’s dynamic variants:
<script lang="ts">
const slide: Variants = {
enter: (dir: number) => ({ x: dir * 120, opacity: 0 }),
center: { x: 0, opacity: 1 },
exit: (dir: number) => ({ x: dir * -120, opacity: 0 })
};
</script>
{#key index}
<div
transition:presence={{ mode: 'popLayout', custom: direction }}
{@attach mercury({
custom: direction,
variants: slide,
initial: 'enter',
animate: 'center',
exit: 'exit'
})}
>
{slides[index]}
</div>
{/key}
<script lang="ts">
import { mercury, presence, type Variants } from '@omicrxn/mercury';
const slides = ['One', 'Two', 'Three', 'Four'];
let index = $state(0);
let direction = $state(1);
function paginate(step: number) {
direction = step;
index = (index + step + slides.length) % slides.length;
}
// Variants receive `custom`: the exiting slide reads the *latest* direction from
// `presence`, so it leaves the way the new one comes from.
const slide: Variants = {
enter: (dir: number) => ({ x: dir * 120, opacity: 0 }),
center: { x: 0, opacity: 1 },
exit: (dir: number) => ({ x: dir * -120, opacity: 0 })
};
</script>
<div class="flex flex-col items-center gap-4">
<div class="relative flex h-32 w-56 items-center justify-center overflow-hidden">
{#key index}
<div
class="box flex size-28! items-center justify-center text-lg font-semibold text-demo-fill-foreground"
transition:presence={{ mode: 'popLayout', custom: direction }}
{@attach mercury({
custom: direction,
variants: slide,
initial: 'enter',
animate: 'center',
exit: 'exit',
transition: { type: 'spring', duration: 0.4, bounce: 0.15 }
})}
>
{slides[index]}
</div>
{/key}
</div>
<div class="flex gap-2">
<button
type="button"
class="min-h-11 rounded-md border border-demo-stroke px-4 text-sm font-medium"
onclick={() => paginate(-1)}>Previous</button
>
<button
type="button"
class="min-h-11 rounded-md border border-demo-stroke px-4 text-sm font-medium"
onclick={() => paginate(1)}>Next</button
>
</div>
</div>
Shorthand
Without an attachment, describe the hidden state on presence:
{#if show}
<div
class="card"
transition:presence={{
initial: { opacity: 0, y: 25 },
exit: { opacity: 0, y: 25 },
transition: { duration: 0.3 }
}}
>
Hello
</div>
{/if}
initial— the hidden state the element enters from. It animates from these values to its own CSS styles.exit— the values the element animates to before it’s removed.transition— shared by both directions. Each side can override it with its owntransition, e.g.exit: { opacity: 0, transition: { duration: 0.15 } }.
Your CSS stays the resting state: once an enter finishes, Mercury hands the animated properties back to the element’s styles, so an element that entered from height: 0 keeps following its content, and an opacity class keeps applying. Transform values (x, y, scale, rotate…) settle at their identity.
For simple cases, skip initial/exit and pass the hidden state directly. Enters animate from it, exits to it:
<div transition:presence={{ opacity: 0, y: 25 }} />
<!-- or per direction -->
<div in:presence={{ opacity: 0 }} out:presence={{ opacity: 0, y: 25 }} />
Options
These go on presence with either style:
mode—'sync' | 'wait' | 'popLayout', matching<AnimatePresence mode>.custom— data for the exit’s dynamic variants (see above).onExitComplete— called once the exit has finished, right before the element is removed.
<div transition:presence={{ onExitComplete: () => console.log('gone') }} />
First render (initial={false})
Svelte transitions are local by default: they don’t play when an ancestor block mounts, only when the element’s own block is added or removed. For the shorthand that’s exactly <AnimatePresence initial={false}>; opt in to Motion’s default with |global:
<div transition:presence|global={{ initial: { opacity: 0 }, exit: { opacity: 0 } }} />
With the Motion-style attachment, initial plays whenever the element mounts, like a motion.div. Use initial: false to skip it.
Modes (sync, wait and popLayout)
Quoting Motion’s documentation:
- “sync”: Children animate in/out as soon as they’re added/removed.
- “wait”: The entering child will wait until the exiting child has animated out. Note: Currently only renders a single child at a time.
- “popLayout”: Exiting children will be “popped” out of the page layout. This allows surrounding elements to move to their new layout immediately.
{#key currentTab}
<section
transition:presence={{
initial: { opacity: 0, scale: 0.9 },
exit: { opacity: 0, scale: 0.9 },
mode: 'popLayout',
transition: { duration: 0.25 }
}}
>
{currentTab.content}
</section>
{/key}
mode: 'sync'(default) — entering and exiting elements animate at the same time.mode: 'wait'— the entering element stays out of the page (display: none) until the exiting one has animated out and been removed, then animates in. Mercury coordinates this forpresenceandmercury()enters under the same parent element.mode: 'popLayout'— the exiting element is pinned where it is withposition: absoluteat its measured size and offset, so siblings reflow immediately while it animates out. Its parent becomesposition: relativewhile any child is popped, if it wasstatic.
Interruptions
Presence animations are interruptible, like Motion’s:
- If an element is re-inserted while its exit is still running (a quick toggle back), it animates from wherever the exit left off back to its resting state, instead of replaying the enter.
- If an exit starts mid-enter, it animates from the current state.
Caveats & differences from Motion
presence is built directly on Svelte transitions instead of a wrapper component. The common cases map over cleanly, but a few rules come from Svelte’s model rather than Mercury and are worth knowing up front.
Exits inside a conditional parent need |global
Svelte does not play a local out: transition when a parent block ({#if}, {#key}, {#each}) is the thing being removed — it only plays local transitions when the element’s own block is removed. If an ancestor block is destroyed, add the |global modifier so the exit still runs:
{#if open}
{#if state === 'success'}
<Success />
{:else}
<!-- the parent {:else} block is destroyed on success, so |global is required -->
<form out:presence|global={{ opacity: 0, y: 8, mode: 'popLayout' }}>…</form>
{/if}
{/if}
Rule of thumb: if the element’s own {#if} toggles, local presence is fine; if an ancestor block is removed, use presence|global.
exit needs presence
Like Motion’s exit outside <AnimatePresence>, an exit on the attachment does nothing unless the element has transition:presence or out:presence: without a transition, Svelte removes the element immediately.
mode is set per element, not on a wrapper
Motion sets mode once on <AnimatePresence>. In Mercury the mode lives on each element’s presence. For a list, set the same mode on every item.
popLayout reflows siblings instantly
Motion pairs popLayout with the layout prop so neighbours animate to their new positions. Mercury’s popLayout pops the exiting element out of flow; for a smooth reflow, add Mercury’s layout to the siblings.
wait coordinates through the shared parent
wait sequences the next enter after the current exit through the shared parent element. The entering and exiting elements must live under the same DOM parent — keyed swaps ({#key}) and {#if} / {:else} blocks normally satisfy this.