Skip to content
Esc
↑↓navigate↵open⌘Jpreview
On this page

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, animate and exit on the element’s mercury() attachment, with presence marking it as a presence child. This supports everything Motion’s exit does — variants, custom, orchestrated children.
  • Shorthand: initial and exit on presence itself, 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 own transition, 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 for presence and mercury() enters under the same parent element.
  • mode: 'popLayout' — the exiting element is pinned where it is with position: absolute at its measured size and offset, so siblings reflow immediately while it animates out. Its parent becomes position: relative while any child is popped, if it was static.

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.

Was this page helpful?