---
title: Layout Animations
description: 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()`.

```svelte
<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.

<Component path="flip" />

### 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, `Map`s and `Set`s (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])`.

```svelte
<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.

<Component path="flip-array" />

## Shared layout IDs

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

```svelte
<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.

<Component path="layout-id" />

## 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'`.

```svelte
<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' })`.

<Component path="layout-position" />

## 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'`.

```ts
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](/docs/api/animate) 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.

```svelte
<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.

```ts
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.

<Component path="layout-animate" />

## Marking elements with `mercury()`

Elements that already have a [`mercury()`](/docs/api/animate) attachment can be marked through it instead, with Motion's prop names:

```svelte
<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 })`](/docs/api/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.
