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

Layout Animations

Animate position, size, reordering, and shared elements with Mercury's layout API.

Overview

Mercury uses Motion’s layout projection engine to animate changes to an element’s position and size. Update your Svelte state and CSS layout as usual, including justify-content, flex-direction, and grid-template-columns. Mercury measures the old and new layouts and animates between them with transforms.

Basic usage

Create a layout group in your component’s <script>, attach it to the example’s container, and mark the elements that should animate with layout.props().

<script lang="ts">
	import { layout } from '@omicrxn/mercury';

	let justify = $state('start');
	const layoutGroup = layout(() => justify, {
		transition: { type: 'spring', duration: 0.4, bounce: 0.2 }
	});
</script>

<div
	{@attach layoutGroup}
	class="flex w-64 gap-2"
	style:justify-content={justify}
>
	<div {...layout.props()} class="h-16 w-16 rounded-md bg-indigo-200"></div>
</div>
<button onclick={() => (justify = justify === 'start' ? 'end' : 'start')}>
	Flip
</button>

The getter passed to layout() identifies the reactive state that changes the layout. Create the group once in <script> so Mercury can measure elements before Svelte updates the DOM. Create it in the component whose markup the change updates, or in one of its ancestors: Svelte updates parent components first, so a group in a child component measures after its parent’s markup has already changed.

<script lang="ts">
	import { layout } from '@omicrxn/mercury';
	let justify = $state('start');

	const layoutGroup = layout(() => justify, {
		transition: { type: 'spring', duration: 0.3, bounce: 0 }
	});

	const flip = () => {
		if (justify === 'start') {
			justify = 'end';
		} else {
			justify = 'start';
		}
	};

	const buttonClass =
		'rounded-md border border-demo-stroke bg-demo-fill px-4 py-2 text-sm font-medium text-demo-fill-foreground transition-colors hover:bg-demo-fill-hover';
</script>

<div class="flex min-h-80 flex-col justify-center gap-8 p-8">
	<div class="mx-auto flex w-full max-w-2xl flex-col gap-6">
		<h2 class="text-xl font-bold">Layout Animation</h2>

		<div
			{@attach layoutGroup}
			class="flex min-h-24 w-96 max-w-full gap-2 rounded-md border border-demo-stroke border-dashed bg-demo-surface p-4"
			style="justify-content: {justify};"
		>
			<div
				{...layout.props({ type: 'position' })}
				class="box flex size-16 shrink-0 items-center justify-center rounded-md border border-demo-stroke bg-demo-fill font-medium text-demo-fill-foreground"
			>
				Test
			</div>
			<div
				{...layout.props({ type: 'position' })}
				class="box size-16 shrink-0 rounded-md border border-demo-stroke bg-demo-fill"
			></div>
		</div>

		<button class={buttonClass} onclick={flip}>Flip</button>
	</div>
</div>

Scoping a group

{@attach layoutGroup} limits measurements to marked elements in that container, including the container itself if it has layout.props(). Use a separate group for each independent example or widget.

Without an attachment, layout() measures marked elements throughout the document. Attaching a group limits its measurements, but does not namespace shared layout IDs. Give unrelated shared elements different IDs. For reusable components, prefix shared IDs with Svelte’s $props.id().

Tracking arrays and objects

The trigger tracks nested reactive values in arrays, plain objects, Maps and Sets (including SvelteMap and SvelteSet). Pass them directly to react to push, splice, reordering, or property changes. Class instances aren’t walked: return the fields that matter, for example layout(() => [board.columns, board.cards]).

<script lang="ts">
	import { layout } from '@omicrxn/mercury';

	let items = $state([{ id: 1 }, { id: 2 }, { id: 3 }]);
	let nextId = 4;
	const layoutGroup = layout(() => items, {
		transition: { duration: 0.4 }
	});
</script>

<div {@attach layoutGroup} class="flex flex-wrap gap-2">
	{#each items as item (item.id)}
		<div
			{...layout.props({ type: 'position' })}
			class="flex h-16 w-16 items-center justify-center rounded-md bg-indigo-200"
		>
			{item.id}
		</div>
	{/each}
</div>
<button onclick={() => items.push({ id: nextId++ })}>Add item</button>
<button onclick={() => items.reverse()}>Reverse</button>

Use a keyed {#each} block to preserve each item’s DOM element. Those elements only need layout.props(). Reserve shared IDs for transitions between different elements.

If several values affect the layout, return them together, for example layout(() => [items, columns]). A state change that does not move or resize marked elements starts no layout animation.

<script lang="ts">
	import { layout } from '@omicrxn/mercury';
	let items = $state([{ id: 1 }, { id: 2 }, { id: 3 }]);
	let nextId = 4;
	let justify = $state('start');

	const layoutGroup = layout(() => [items, justify], {
		transition: { type: 'spring', duration: 0.3, bounce: 0 }
	});

	const flip = () => {
		if (justify === 'start') {
			justify = 'end';
		} else {
			justify = 'start';
		}
	};

	function shuffle() {
		for (let index = items.length - 1; index > 0; index--) {
			const other = Math.floor(Math.random() * (index + 1));
			[items[index], items[other]] = [items[other], items[index]];
		}
	}

	const buttonClass =
		'rounded-md border border-demo-stroke bg-demo-fill px-4 py-2 text-sm font-medium text-demo-fill-foreground transition-colors hover:bg-demo-fill-hover';
</script>

<div class="flex min-h-80 flex-col justify-center gap-8 p-8">
	<div class="mx-auto flex w-full max-w-2xl flex-col gap-6">
		<h2 class="text-xl font-bold">Layout Animation: Array</h2>

		<div
			{@attach layoutGroup}
			class="flex min-h-24 w-full flex-wrap gap-2 rounded-md border border-demo-stroke border-dashed bg-demo-surface p-4"
			style="justify-content: {justify};"
		>
			{#each items as item (item.id)}
				<div
					{...layout.props({ type: 'position' })}
					class="box flex size-16 shrink-0 items-center justify-center rounded-md border border-demo-stroke bg-demo-fill font-medium text-demo-fill-foreground"
				>
					{item.id}
				</div>
			{/each}
		</div>

		<div class="flex flex-wrap gap-2">
			<button
				class={buttonClass}
				onclick={() => {
					items.push({ id: nextId++ });
				}}
			>
				Add item
			</button>
			<button
				class={buttonClass}
				onclick={() => {
					items.splice(Math.floor(items.length / 2), 1);
				}}
			>
				Remove middle item
			</button>
			<button class={buttonClass} onclick={flip}>Flip</button>
			<button class={buttonClass} onclick={shuffle}>Shuffle</button>
		</div>
	</div>
</div>

Shared layout IDs

Give two different elements the same layout.props('id') to animate between them when a conditional swaps one for the other.

<script lang="ts">
	import { layout } from '@omicrxn/mercury';

	let expanded = $state(false);
	const id = $props.id();
	const layoutGroup = layout(() => expanded, {
		transition: { type: 'spring', duration: 0.4, bounce: 0.15 }
	});
</script>

<div {@attach layoutGroup}>
	{#if expanded}
		<div {...layout.props(`${id}-card`)} class="w-64 rounded-2xl bg-indigo-200 p-8">
			<p {...layout.props(`${id}-label`, { type: 'position' })}>Details</p>
		</div>
	{:else}
		<div {...layout.props(`${id}-card`)} class="w-32 rounded-md bg-indigo-200 p-4">
			<p {...layout.props(`${id}-label`, { type: 'position' })}>Details</p>
		</div>
	{/if}
</div>
<button onclick={() => (expanded = !expanded)}>
	{expanded ? 'Collapse' : 'Expand'}
</button>

The card and its label each have a shared ID. Marking the label with type: 'position' keeps its text from stretching while the card changes size.

Render a moving tab indicator inside {#if selected}. Rendering an indicator for every tab and hiding the inactive ones with CSS leaves multiple elements competing for the same ID.

Mercury also supports an overlapping pair, such as a card that stays mounted behind its expanded view. Adding the second element crossfades to it; removing it animates back to the original. While the second element is shown, the original keeps its place in the layout but is invisible. Use this intentionally when the original should remain mounted.

<script lang="ts">
	import { layout } from '@omicrxn/mercury';
	let showSecond = $state(false);

	const layoutGroup = layout(() => showSecond, {
		transition: { type: 'spring', duration: 0.3, bounce: 0 }
	});

	const buttonClass =
		'rounded-md border border-demo-stroke bg-demo-fill px-4 py-2 text-sm font-medium text-demo-fill-foreground transition-colors hover:bg-demo-fill-hover';
</script>

<div class="flex min-h-80 flex-col justify-center gap-8 p-8">
	<div class="mx-auto flex w-full max-w-2xl flex-col gap-6">
		<h2 class="text-xl font-bold">Layout Animation: Layout ID</h2>

		<div
			class="flex min-h-48 w-full items-center justify-center rounded-md border border-demo-stroke border-dashed bg-demo-surface p-8"
		>
			<div {@attach layoutGroup}>
				{#if showSecond}
					<div
						{...layout.props('rectangle')}
						class="size-24 rounded-xl border border-demo-stroke bg-demo-fill"
					>
						<div
							{...layout.props('rectangle-square')}
							class="size-4 rounded-md border m-2 border-demo-stroke bg-amber-200"
						></div>
					</div>
				{:else}
					<div
						{...layout.props('rectangle')}
						class="size-12 rounded-xl border border-demo-stroke bg-demo-fill"
					>
						<div
							{...layout.props('rectangle-square')}
							class="m-4 size-4 rounded-md bg-purple-400"
						></div>
					</div>
				{/if}
			</div>
		</div>

		<button class={buttonClass} onclick={() => (showSecond = !showSecond)}>
			{showSecond ? 'Shrink' : 'Expand'}
		</button>
	</div>
</div>

Position and size

An element marked with layout.props() animates both position and size. Mark its children too when they need scale correction during a parent resize. For text and icons that should only move, use type: 'position'.

<div {...layout.props()} style:width={expanded ? '320px' : '160px'}>
	<p {...layout.props({ type: 'position' })}>Position only</p>
</div>

Use this markup inside a group that watches expanded.

type Animates
Default Position and size
'position' Position only; size changes apply instantly
'size' Size only
'preserve-aspect' Position and size when the aspect ratio stays the same, otherwise position only

layout.props accepts options, an ID, or both: layout.props('card-title', { type: 'position' }).

<script lang="ts">
	import { layout } from '@omicrxn/mercury';

	let expanded = $state(false);
	let settings = $state({ align: 'start' });

	// The trigger tracks changes to settings.align.
	const layoutGroup = layout(() => [expanded, settings], {
		transition: { type: 'spring', duration: 0.3, bounce: 0 },
		shared: { pill: { type: 'spring', bounce: 0.1, duration: 0.35 } }
	});

	const buttonClass =
		'rounded-md border border-demo-stroke bg-demo-fill px-4 py-2 text-sm font-medium text-demo-fill-foreground transition-colors hover:bg-demo-fill-hover';
</script>

<div class="flex min-h-80 flex-col justify-center gap-8 p-8">
	<div {@attach layoutGroup} class="mx-auto flex w-full max-w-2xl flex-col gap-6">
		<div class="flex flex-col gap-1">
			<h2 class="text-xl font-bold">Layout Animation: Position, Shared Transitions</h2>
			<p class="text-sm text-muted-foreground">
				The card animates its size; its text only animates its position, so it never stretches.
			</p>
		</div>

		<div
			class="flex min-h-48 w-full rounded-md border border-dashed border-demo-stroke bg-demo-surface p-4"
			style:justify-content={settings.align}
		>
			<div
				{...layout.props()}
				class="max-w-full rounded-md border border-demo-stroke bg-demo-fill p-4"
				style:width={expanded ? '320px' : '160px'}
				style:height={expanded ? '160px' : '80px'}
			>
				<p {...layout.props({ type: 'position' })} class="text-sm font-medium text-demo-fill-foreground">
					Position only
				</p>
			</div>
		</div>

		<div class="flex h-10 w-full items-center rounded-full border border-demo-stroke bg-demo-surface p-1">
			{#each ['start', 'center', 'end'] as align (align)}
				<div
					class={[
						'relative flex h-full flex-1 items-center justify-center text-sm transition-colors',
						settings.align === align ? 'text-demo-fill-foreground' : 'text-foreground'
					]}
				>
					{#if settings.align === align}
						<div
							{...layout.props('pill')}
							class="absolute inset-0 bg-demo-fill"
							style="border-radius: 9999px;"
						></div>
					{/if}
					<span class="relative">{align}</span>
				</div>
			{/each}
		</div>

		<div class="flex flex-wrap gap-2">
			<button class={buttonClass} onclick={() => (expanded = !expanded)}>
				{expanded ? 'Collapse' : 'Expand'}
			</button>
			<button class={buttonClass} onclick={() => (settings.align = 'start')}>Start</button>
			<button class={buttonClass} onclick={() => (settings.align = 'center')}>Center</button>
			<button class={buttonClass} onclick={() => (settings.align = 'end')}>End</button>
		</div>
	</div>
</div>

Options

Pass Motion transition options directly, including spring settings. A layout animation runs once from the old layout to the new one, so repeat and playback options (repeat, autoplay, …) aren’t accepted, and type is 'spring', 'tween' or 'keyframes'.

const layoutGroup = layout(() => selectedTab, {
  transition: { type: 'spring', bounce: 0.2, duration: 0.5 },
  shared: {
    indicator: { type: 'spring', bounce: 0.4, duration: 0.6 }
  },
  callbacks: {
    onPlay: () => console.log('Layout animation started'),
    onComplete: () => console.log('Layout animation finished')
  }
});
  • transition sets the transition for elements that change layout.
  • shared overrides the transition for a particular shared ID, such as layout.props('indicator') above.
  • callbacks.onPlay runs when a change starts layout animations. callbacks.onComplete runs after completion, including updates that start no animations. A change interrupted by another layout change doesn’t complete; the new change reports its own.

Pass an options getter, such as layout(() => selectedTab, () => ({ transition })), to read the options again on each change.

Layout changes apply immediately when the user prefers reduced motion.

Animating a one-off change

Use layout.animate(update, options) to capture a layout change from an event handler. Put the state or DOM change inside update and pass a container as scope to limit measurements.

<script lang="ts">
	import { layout } from '@omicrxn/mercury';

	let columns = $state(4);
	let grid = $state<HTMLDivElement>();
	const items = [1, 2, 3, 4, 5, 6, 7, 8];

	function toggleColumns() {
		return layout.animate(() => (columns = columns === 4 ? 2 : 4), {
			scope: grid,
			transition: { type: 'spring', duration: 0.5, bounce: 0.2 }
		});
	}
</script>

<div
	bind:this={grid}
	class="grid w-full gap-2"
	style:grid-template-columns="repeat({columns}, minmax(0, 1fr))"
>
	{#each items as item (item)}
		<div {...layout.props()} class="rounded-md bg-indigo-200 p-4">
			<span {...layout.props({ type: 'position' })}>{item}</span>
		</div>
	{/each}
</div>
<button onclick={toggleColumns}>Toggle columns</button>

This takes the same options as layout(), plus scope. If omitted, the scope is the whole document. Use either a reactive group or layout.animate() for a given update so the same change is not measured twice.

The returned promise resolves with Motion’s playback controls. Wait for finished when subsequent work should happen after the animation.

const animation = await layout.animate(() => (columns = 2), { scope: grid });
await animation.finished;

The example below also uses layout.animate() to toggle a CSS class directly on the grid.

<script lang="ts">
	import { layout } from '@omicrxn/mercury';

	let columns = $state(4);
	let stage = $state<HTMLElement>();
	let log = $state('Ready');

	const items = Array.from({ length: 8 }, (_, index) => index + 1);

	// Capture the current layout before changing the column count.
	const setColumns = (next: number) =>
		layout.animate(() => (columns = next), {
			transition: { type: 'spring', bounce: 0, duration: 0.3 },
			scope: stage,
			callbacks: {
				onPlay: () => (log = 'playing'),
				onComplete: () => (log = 'complete')
			}
		});

	// Also works for DOM changes Svelte doesn't know about.
	const toggleCompact = () =>
		layout.animate(() => stage?.classList.toggle('compact'), {
			scope: stage,
			transition: { type: 'spring', duration: 0.3, bounce: 0 }
		});

	const buttonClass =
		'rounded-md border border-demo-stroke bg-demo-fill px-4 py-2 text-sm font-medium text-demo-fill-foreground transition-colors hover:bg-demo-fill-hover';
</script>

<div class="flex min-h-80 flex-col justify-center gap-8 p-8">
	<div class="mx-auto flex w-full max-w-2xl flex-col gap-6">
		<div class="flex flex-col gap-1">
			<h2 class="text-xl font-bold">Layout Animation: layout.animate()</h2>
			<p class="text-sm text-muted-foreground">
				Animation status: <span data-log>{log}</span>
			</p>
		</div>

		<div
			bind:this={stage}
			class="stage grid w-full gap-2 rounded-md border border-dashed border-demo-stroke bg-demo-surface p-4"
			style:grid-template-columns="repeat({columns}, minmax(0, 1fr))"
		>
			{#each items as item (item)}
				<div
					{...layout.props()}
					class="tile flex h-16 items-center justify-center rounded-md border border-demo-stroke bg-demo-fill font-medium text-demo-fill-foreground"
				>
					<span {...layout.props({ type: 'position' })}>{item}</span>
				</div>
			{/each}
		</div>

		<div class="flex flex-wrap gap-2">
			<button class={buttonClass} onclick={() => setColumns(2)}>2 columns</button>
			<button class={buttonClass} onclick={() => setColumns(4)}>4 columns</button>
			<button class={buttonClass} onclick={() => setColumns(8)}>8 columns</button>
			<button class={buttonClass} onclick={toggleCompact}>Toggle compact</button>
		</div>
	</div>
</div>

<style>
	.stage:global(.compact) .tile {
		height: 2rem;
	}
</style>

Marking elements with mercury()

Elements that already have a mercury() attachment can be marked through it instead, with Motion’s prop names:

<div {@attach mercury({ layout: true, whileHover: { scale: 1.05 } })}></div>
<div {@attach mercury({ layoutId: 'underline' })}></div>
<p {@attach mercury({ layout: 'position' })}>Moves without stretching</p>

layout takes true, 'position', 'size' or 'preserve-aspect'. They also accept Motion’s other layout props:

  • onLayoutAnimationStart / onLayoutAnimationComplete — this element’s layout animation started or finished.
  • layoutScroll — on a scrollable container, accounts for its scroll offset when measuring the children.
  • layoutRoot — on a position: fixed container, measures the children relative to it.
  • layoutCrossfade: false — shared elements swap without crossfading.

The layout animation itself still runs when the group’s trigger changes. A layout animation and the element’s other animations share its motion values, so a hovered element can move to a new layout while it scales.

Mapping from Motion

motion/react Mercury
<motion.div layout /> <div {...layout.props()} /> or mercury({ layout: true })
<motion.div layoutId="card" /> <div {...layout.props('card')} /> or mercury({ layoutId: 'card' })
<motion.div layout="position" /> <div {...layout.props({ type: 'position' })} />
layoutDependency={value} layout(() => value)
transition={{ layout: { … } }} layout(trigger, { transition: { … } })

Svelte updates the DOM without re-rendering the whole component, so the reactive API needs an explicit trigger. layout.props() writes data-layout and, when supplied, data-layout-id. You can also write the attributes directly, for example <div data-layout="position">.

Border radius

Mercury corrects border-radius while an element scales and interpolates the radius between shared elements. Radii can come from classes or inline styles, in pixels or percentages.

Things to know

  • Transforms. Mercury animates marked elements with transform and leaves transform: none inline when they settle, so a transform from your CSS no longer applies to them. Animate transforms with mercury({ animate }), or put them on a wrapper. Tailwind 4’s translate-*, scale-* and rotate-* utilities use their own CSS properties and keep working.
  • Svelte’s experimental async mode (experimental.async) applies DOM updates before $effect.pre runs, so layout animations don’t work with it yet.

Was this page helpful?