Layout animations
Animate between any two layouts, even between different elements, with performant transforms.
Motion's layout animations can animate between any two layouts, even between different elements. They can animate values CSS can't, like switching justify-content from flex-start to flex-end, and they run on transform, so they stay smooth.
<script lang="ts">
import { createLayoutMotion } from "motion-sv";
let isOn = $state(false);
const layout = createLayoutMotion(() => isOn);
const toggleSwitch = () => (isOn = !isOn);
</script>
<button class={["toggle-container", isOn ? "start" : "end"]} onclick={toggleSwitch}>
<layout.div
data-state={isOn}
class="toggle-handle"
transition={{ type: "spring", visualDuration: 0.2, bounce: 0.2 }}
/>
</button>
<style>
.toggle-container {
box-sizing: content-box;
display: flex;
width: 100px;
height: 50px;
padding: 10px;
border: none;
border-radius: 50px;
background-color: #9911ff44;
cursor: pointer;
}
.toggle-container.start {
justify-content: flex-start;
}
.toggle-container.end {
justify-content: flex-end;
}
.toggle-container :global(.toggle-handle) {
width: 50px;
height: 50px;
border-radius: 50%;
background-color: #9911ff;
}
</style> With layoutId, they can match two different elements and animate from one to the other, for anything from an underline that follows the selected tab to a card that expands into a full view.
<script lang="ts">
import { AnimatePresence, motion } from "motion-sv";
const tabs = [
{ icon: "๐
", label: "Tomato" },
{ icon: "๐ฅฌ", label: "Lettuce" },
{ icon: "๐ง", label: "Cheese" },
];
let selectedTab = $state(tabs[0]);
</script>
<div class="container">
<nav class="nav">
<ul class="tabs-container">
{#each tabs as item (item.label)}
<motion.li
class="tab"
initial={false}
animate={{ backgroundColor: item.label === selectedTab.label ? "#eee" : "#eee0" }}
onclick={() => (selectedTab = item)}
>
{`${item.icon} ${item.label}`}
{#if item.label === selectedTab.label}
<motion.div class="underline" layoutId="underline" />
{/if}
</motion.li>
{/each}
</ul>
</nav>
<div class="icon-container">
<AnimatePresence mode="wait">
{#key selectedTab.label}
<motion.div
class="icon"
initial={{ y: 10, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: -10, opacity: 0 }}
transition={{ duration: 0.2 }}
>
{selectedTab.icon}
</motion.div>
{/key}
</AnimatePresence>
</div>
</div>
<style>
.container {
display: flex;
flex-direction: column;
width: 480px;
height: 360px;
max-width: calc(100% - 40px);
max-height: calc(100% - 40px);
border-radius: 10px;
background: white;
overflow: hidden;
box-shadow:
0 1px 1px hsl(0deg 0% 0% / 0.075),
0 2px 2px hsl(0deg 0% 0% / 0.075),
0 4px 4px hsl(0deg 0% 0% / 0.075),
0 8px 8px hsl(0deg 0% 0% / 0.075),
0 16px 16px hsl(0deg 0% 0% / 0.075),
0 2px 2px hsl(0deg 0% 0% / 0.075),
0 4px 4px hsl(0deg 0% 0% / 0.075),
0 8px 8px hsl(0deg 0% 0% / 0.075),
0 16px 16px hsl(0deg 0% 0% / 0.075);
}
.nav {
height: 44px;
padding: 5px 5px 0;
border-bottom: 1px solid #eeeeee;
border-radius: 10px 10px 0 0;
background: #fdfdfd;
}
.tabs-container {
display: flex;
width: 100%;
margin: 0;
padding: 0;
list-style: none;
font-size: 14px;
font-weight: 500;
}
.tabs-container :global(.tab) {
position: relative;
display: flex;
flex: 1;
justify-content: space-between;
align-items: center;
width: 100%;
min-width: 0;
height: 24px;
margin: 0;
padding: 10px 15px;
border-radius: 5px 5px 0 0;
background: white;
color: #0f1115;
font-size: 14px;
font-weight: 500;
list-style: none;
cursor: pointer;
user-select: none;
}
.tabs-container :global(.underline) {
position: absolute;
right: 0;
bottom: -2px;
left: 0;
height: 2px;
background: #ff0088;
}
.icon-container {
display: flex;
flex: 1;
justify-content: center;
align-items: center;
}
.icon-container :global(.icon) {
font-size: 128px;
}
</style> <script lang="ts">
import { AnimatePresence, motion } from "motion-sv";
interface Card {
id: string;
category: string;
title: string;
content: string[];
theme: "light" | "dark";
background: string;
top?: number;
bottom?: number;
width?: string;
left?: number;
}
// Gradients stand in for the original's photos. "light" cards have light titles on a dark image, "dark" ones the
// reverse.
const items: Card[] = [
{
id: "a",
category: "Travel",
title: "5 Inspiring Apps for Your Next Trip",
content: [
"Love to travel? So do the makers of these five subscription apps. For a small monthly fee, they'll help you find the best deals on flights, hotels, and some other stuff we turn a blind eye to.",
"Plan your perfect itinerary with intelligent recommendations based on your interests, time, and credit history.",
],
top: -300,
theme: "light",
background: "linear-gradient(160deg, #9911ff, #1e75f7)",
},
{
id: "c",
category: "How to",
title: "Contemplate the Meaning of Life Twice a Day",
content: [
`What is life? You can't spell "life" without "i". You also can't spell "life" without "l", "f", and "e". Worth thinking about.`,
"The only way to find out more about life is to think about it. And the only way to think about it is twice daily using an app.",
"Apps? We got 'em. Therefore we got the meaning of life.",
],
bottom: -50,
theme: "dark",
background: "linear-gradient(160deg, #8df0cc, #0cdcf7)",
width: "110%",
left: -20,
},
{
id: "d",
category: "Steps",
title: "Urban Exploration Apps for the Vertically-Inclined",
content: [
"Get off the beaten path. Find the best views, skywalks, and elevated gardens in your city.",
"Locked door? No problem! This app crowdsources the access code to every door in your city.",
],
theme: "dark",
width: "200%",
background: "linear-gradient(160deg, #0cdcf7, #e0fbf2)",
left: -100,
},
{
id: "b",
category: "Hats",
title: "Take Control of Your Hat Life With This Stunning New App",
content: [
"Whether you're serious hat enthusiast, or just a filthy casual, this new app revolutionizes how you organize, care for, and expand your hat collection.",
"Stay up to date with the latest hat trends, get personalized hat care reminders, and use predictive analytics to discover the last place you left your hat.",
"Why follow the crowd when you can be the crowd?",
],
bottom: -100,
theme: "light",
background: "linear-gradient(160deg, #ff0088, #dd00ee)",
},
];
let openId = $state<string | null>(null);
function open(id: string) {
openId = id;
}
function close() {
openId = null;
}
</script>
<!-- The list card and the open card share layoutIds, so opening and closing animates one into the other -->
{#snippet cardContent(card: Card, isOpen: boolean)}
<motion.div class="card-content" layoutId="card-container-{card.id}">
<motion.div class="card-image-container" layoutId="card-image-container-{card.id}">
<motion.div
class="card-image"
style={{
background: card.background,
top: card.top ?? "auto",
bottom: card.bottom ?? "auto",
width: card.width ?? "100%",
left: card.left ?? "auto",
}}
layoutId="card-image-{card.id}"
/>
</motion.div>
<motion.div class="title-container" layoutId="title-container-{card.id}" layout="position">
<span class="h6">{card.category}</span>
<h2 class="h3">{card.title}</h2>
</motion.div>
{#if isOpen}
<motion.div class="content-container small">
{#each card.content as paragraph}
<p class="big">{paragraph}</p>
{/each}
</motion.div>
{/if}
</motion.div>
{/snippet}
<div class="app-store">
<header>
<div>
<h2 class="store-title">Today</h2>
</div>
<div class="avatar"></div>
</header>
<!-- Store front -->
<div>
<ul class="card-list">
{#each items as card (card.id)}
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_noninteractive_element_interactions -->
<li class={["card", card.theme]} onclick={() => open(card.id)}>
{@render cardContent(card, false)}
</li>
{/each}
</ul>
<!-- Item detail, rendered per card so a closing card keeps its content while it animates back -->
<AnimatePresence>
{#each items as card (card.id)}
{#if openId === card.id}
<motion.div
class="overlay"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2, delay: 0.1 }}
onclick={close}
/>
<div class={["card-content-container", "open", card.theme]}>
{@render cardContent(card, true)}
</div>
{/if}
{/each}
</AnimatePresence>
</div>
</div>
<style>
.app-store {
display: flex;
flex-direction: column;
width: 100%;
max-width: 990px;
margin: 0 auto;
}
.app-store,
.app-store :global(*) {
box-sizing: border-box;
}
header {
position: relative;
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.avatar {
width: 40px;
height: 40px;
border: 1px solid rgb(127 127 127 / 0.25);
border-radius: 50%;
background: linear-gradient(135deg, #ff0088, #9911ff);
}
.store-title {
margin: 5px 0;
font-size: 34px;
font-weight: 640;
line-height: 1;
letter-spacing: -0.05em;
}
ul,
li {
margin: 0;
padding: 0;
list-style: none;
}
.card-list {
display: flex;
flex-wrap: wrap;
align-content: flex-start;
gap: 10px;
}
.card {
position: relative;
flex: 0 1 calc(50% - 5px);
height: 280px;
padding: 0;
}
.card-content-container {
position: relative;
display: flex;
flex-direction: column;
justify-content: flex-end;
width: 100%;
height: 100%;
pointer-events: none;
}
.card-content-container.open {
position: fixed;
top: 0;
right: 0;
left: 0;
z-index: 1000001;
justify-content: center;
padding: 0;
overflow: hidden;
}
.dark .h3,
.dark span {
color: #0f1115;
}
.app-store :global(.card-content) {
position: relative;
width: 100%;
height: 100%;
margin: 0 auto;
border-radius: 20px;
background: #ffffff;
color: #18222e;
overflow: hidden;
pointer-events: auto;
}
.open :global(.card-content) {
width: unset;
height: unset;
max-width: 700px;
overflow: hidden;
pointer-events: none;
}
.app-store :global(.card-image-container) {
position: relative;
display: flex;
flex-direction: column;
justify-content: stretch;
height: 280px;
overflow: hidden;
}
.app-store :global(.card-image) {
position: absolute;
width: 100%;
aspect-ratio: 3 / 2;
}
li :global(.card-image) {
top: unset !important;
right: 0 !important;
bottom: -10px !important;
left: 0 !important;
min-width: 250px;
min-height: 300px;
}
.open :global(.card-image-container),
.open :global(.title-container) {
z-index: 1;
}
.app-store :global(.title-container) {
position: absolute;
top: 15px;
left: 15px;
max-width: 300px;
}
li :global(.title-container) {
visibility: hidden;
}
.open :global(.title-container) {
top: 30px;
left: 30px;
}
.h6 {
font-size: 12px;
font-weight: 590;
line-height: 1;
}
.h3 {
margin: 8px 0;
color: #fff;
font-size: 22px;
font-weight: 530;
line-height: 1.2;
letter-spacing: -0.02em;
}
.app-store :global(.overlay) {
position: fixed;
inset: 0;
z-index: 1000000;
background: rgba(0, 0, 0, 0.8);
will-change: opacity;
}
.app-store :global(.content-container) {
width: 90vw;
max-width: 700px;
padding: 35px;
}
.app-store :global(.small) {
font-size: 15px;
font-weight: 480;
line-height: 1.4;
letter-spacing: -0.01em;
}
.big {
margin: 0;
color: rgb(127 127 127);
font-size: 16px;
line-height: 1.3;
}
</style> Layout animations are the one part of motion-sv that works differently from Motion for React and Vue. Read how they work in Svelte first, and the rest of this page will feel familiar.
How layout animations work in Svelte
To animate a layout change, Motion needs to measure each element twice: once before the change, and once after. It then animates the element from the old box to the new one with transform.
React and Vue take the "before" measurement for you, in a hook that runs right before every re-render. Svelte updates the DOM directly and has no such hook, so motion-sv needs to know which state the layout depends on:
- Create a layout namespace with
createLayoutMotion(), and pass it a getter for the state the layout depends on. The namespace works likemotion:layout.div,layout.li,layout.buttonand so on. - Render the elements that should animate through it. Its components animate their layout by default, as if they had the
layoutprop. - Change the state. Whenever the state the getter reads changes, the namespace measures its elements before Svelte updates the DOM. Once the DOM has updated, they animate to their new layout, before the browser paints.
<script lang="ts">
import { createLayoutMotion } from "motion-sv";
let isOn = $state(false);
const layout = createLayoutMotion(() => isOn);
</script>
<button class={["toggle-container", isOn ? "start" : "end"]} onclick={() => (isOn = !isOn)}>
<layout.div class="toggle-handle" />
</button> The getter works like $derived: the state it reads is tracked, and the change can come from anywhere, like an event handler, a timer, code after an await, or a prop. Read all the state that moves the elements, for instance () => [mode, items.length]. For a list that changes in place, like with items.reverse(), read every item: () => [...items].
Updating manually
Without a dependency, tell the namespace about a change yourself, by calling layout.update() with the state change:
<script lang="ts">
import { createLayoutMotion } from "motion-sv";
const layout = createLayoutMotion();
let isOn = $state(false);
function toggleSwitch() {
isOn = !isOn;
layout.update();
}
</script> update.with() wraps a function so that it calls update() for you, which is handy for event handlers:
<script lang="ts">
const toggleSwitch = layout.update.with(() => (isOn = !isOn));
</script> Call update() in the same synchronous code as the state change. Svelte applies changes to the DOM after your code finishes, so it doesn't matter whether update() comes before or after the assignment. It must not come after an await or a flushSync(), because by then the DOM has already changed.
A namespace with a dependency has update() too, for changes its dependency doesn't cover.
Skipping an update
Return STOP_UPDATE from a function wrapped with update.with() to skip the layout update, for instance when nothing changed:
<script lang="ts">
import { createLayoutMotion, STOP_UPDATE } from "motion-sv";
const layout = createLayoutMotion();
const select = layout.update.with((id: string) => {
if (id === selected) return STOP_UPDATE;
selected = id;
});
</script> Usage
<script lang="ts">
import { AnimatePresence, createLayoutMotion } from "motion-sv";
let notifications = $state([0]);
let newIndex = 0;
// The list changes in place, so depend on its length
const layout = createLayoutMotion(() => notifications.length);
function addNotification() {
notifications.push(++newIndex);
}
function removeNotification(id: number) {
notifications.splice(notifications.indexOf(id), 1);
}
</script>
<div class="notifications">
<layout.ul>
<AnimatePresence initial={false} mode="popLayout">
{#each notifications as id (id)}
<layout.li
initial={{ opacity: 0, y: 50, scale: 0.3 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, scale: 0.5, transition: { duration: 0.2 } }}
>
<div class="placeholder-title"></div>
<div class="placeholder-message"></div>
<button class="close" aria-label="Close" onclick={() => removeNotification(id)}>
<svg width="18" height="18" viewBox="0 0 23 23">
<path
d="M 3 16.5 L 17 2.5"
fill="transparent"
stroke-width="3"
stroke="#7f7f7f"
stroke-linecap="round"
/>
<path
d="M 3 2.5 L 17 16.346"
fill="transparent"
stroke-width="3"
stroke="#7f7f7f"
stroke-linecap="round"
/>
</svg>
</button>
</layout.li>
{/each}
</AnimatePresence>
</layout.ul>
<button class="add" onclick={addNotification}>Add notification +</button>
</div>
<style>
.notifications {
position: relative;
display: flex;
flex-direction: column;
width: 100%;
height: 500px;
pointer-events: none;
}
.notifications :global(ul) {
position: absolute;
bottom: 0;
left: calc(50% - 150px);
display: flex;
flex-direction: column;
justify-content: flex-end;
gap: 10px;
width: 300px;
margin: 0;
padding: 10px;
list-style: none;
}
.notifications :global(li) {
position: relative;
display: flex;
flex: 0 0 100px;
flex-direction: column;
gap: 10px;
margin: 0;
padding: 20px 80px 20px 20px;
border: 1px solid rgb(127 127 127 / 0.25);
border-radius: 10px;
background: rgb(127 127 127 / 0.12);
pointer-events: all;
}
.placeholder-title {
width: 200px;
height: 24px;
border-radius: 5px;
background: rgb(127 127 127);
}
.placeholder-message {
width: 150px;
height: 14px;
border-radius: 5px;
background: rgb(127 127 127);
opacity: 0.5;
}
button {
padding: 0;
border: none;
background: none;
cursor: pointer;
pointer-events: all;
}
.add {
position: absolute;
bottom: 10px;
left: 10px;
display: flex;
justify-content: center;
align-items: center;
padding: 10px 12px;
border-radius: 10px;
background: #8df0cc;
color: #0f1115;
font: inherit;
font-size: 18px;
line-height: normal;
}
.close {
position: absolute;
top: 15px;
right: 10px;
}
</style> Any layout change the namespace measures can be animated: width and height, grid columns, flex direction, reordering a list, or adding and removing items.
<layout.div style={{ width: isOpen ? "80vw" : 0 }} /> <script lang="ts">
import { createLayoutMotion } from "motion-sv";
let isOpen = $state(false);
const layout = createLayoutMotion(() => isOpen);
const toggle = () => (isOpen = !isOpen);
</script>
<div class="resize">
<layout.div
class={["card", { open: isOpen }]}
style={{ borderRadius: 20 }}
transition={{ type: "spring", visualDuration: 0.35, bounce: 0.2 }}
onclick={toggle}
>
<layout.span class="label" layout="position">{isOpen ? "Click to close" : "Open"}</layout.span>
</layout.div>
</div>
<style>
.resize :global(.card) {
display: flex;
align-items: center;
justify-content: center;
width: 100px;
height: 100px;
background: #ff3e00;
color: white;
font-weight: 600;
cursor: pointer;
}
.resize :global(.card.open) {
width: 320px;
height: 180px;
}
</style> Change CSS immediately, through style or a class, rather than through animate. The layout animation animates it for you.
Reordering a list is a layout change too. Here, the list is shuffled every second, and the namespace depends on its order:
<script lang="ts">
import { createLayoutMotion } from "motion-sv";
const initialOrder = ["#ff0088", "#dd00ee", "#9911ff", "#0d63f8"];
let order = $state([...initialOrder]);
const layout = createLayoutMotion(() => order);
const reorder = () => (order = shuffle(order));
$effect(() => {
const intervalId = setInterval(reorder, 1000);
return () => clearInterval(intervalId);
});
function shuffle([...array]: string[]) {
return array.sort(() => Math.random() - 0.5);
}
const spring = { type: "spring", damping: 20, stiffness: 300 } as const;
</script>
<ul class="container">
{#each initialOrder as backgroundColor (backgroundColor)}
<layout.li transition={spring} style={{ backgroundColor, order: order.indexOf(backgroundColor) }} />
{/each}
</ul>
<style>
.container {
position: relative;
display: flex;
flex-flow: row wrap;
justify-content: center;
align-items: center;
gap: 10px;
width: 300px;
margin: 0;
padding: 0;
list-style: none;
}
.container :global(li) {
width: 100px;
height: 100px;
border-radius: 10px;
}
</style> Adding and removing items works the same way. Here, each item is rendered through the namespace, which depends on the list. AnimatePresence keeps removed items around for their exit animation, while the others move into place.
<script lang="ts">
import { AnimatePresence, createLayoutMotion } from "motion-sv";
let items = $state([1, 2, 3]);
const layout = createLayoutMotion(() => items);
const remove = (id: number) => (items = items.filter((item) => item !== id));
</script>
<ul>
<AnimatePresence>
{#each items as id (id)}
<layout.li exit={{ opacity: 0 }} onclick={() => remove(id)}>Item {id}</layout.li>
{/each}
</AnimatePresence>
</ul> <script lang="ts">
import { AnimatePresence, createLayoutMotion } from "motion-sv";
let nextId = 4;
let items = $state([1, 2, 3]);
const layout = createLayoutMotion(() => items);
const add = () => (items = [nextId++, ...items]);
const remove = (id: number) => (items = items.filter((item) => item !== id));
</script>
<div class="list-demo">
<button class="add" onclick={add}>Add item</button>
<ul>
<AnimatePresence>
{#each items as id (id)}
<layout.li
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
onclick={() => remove(id)}
>
Item {id}
</layout.li>
{/each}
</AnimatePresence>
</ul>
</div>
<style>
.list-demo {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
width: 260px;
}
.add {
padding: 8px 16px;
border: none;
border-radius: 8px;
background: #ff3e00;
color: white;
font: inherit;
cursor: pointer;
}
ul {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
margin: 0;
padding: 0;
list-style: none;
}
ul :global(li) {
padding: 12px 16px;
border-radius: 10px;
background: rgb(255 62 0 / 0.15);
cursor: pointer;
}
</style> Sharing a namespace
Each createLayoutMotion() call creates its own namespace, which measures only its own elements. When a change affects elements in several components, render them all through the same namespace. It's a plain object, so pass it down as a prop or through context.
<!-- list.svelte -->
<script lang="ts">
import { createLayoutMotion } from "motion-sv";
import Row from "./row.svelte";
let { rows } = $props();
const layout = createLayoutMotion(() => rows);
</script>
{#each rows as row (row.id)}
<Row {row} {layout} />
{/each} <!-- row.svelte -->
<script lang="ts">
import type { LayoutMotionNamespace } from "motion-sv";
let { row, layout }: { row: Row; layout: LayoutMotionNamespace } = $props();
</script>
<layout.li>{row.title}</layout.li> When separate namespaces affect each other's layout, wrap their elements in a LayoutGroup instead.
m, LazyMotion and custom components
createLayoutMotion() builds on motion by default. Pass m first to use it with LazyMotion, and the dependency, if any, second:
<script lang="ts">
import { createLayoutMotion, m } from "motion-sv";
let isOn = $state(false);
const layout = createLayoutMotion(m, () => isOn);
</script> Like motion.create(), layout.create() turns a component into a layout-animated motion component:
<script lang="ts">
import Card from "./card.svelte";
const LayoutCard = layout.create(Card);
</script> Shared layout animations
When an element with a layoutId mounts while another element with the same layoutId leaves, it animates out from the old one. This works with plain motion components, without a layout namespace.
{#each tabs as item (item.label)}
<motion.li onclick={() => (selectedTab = item)}>
{item.label}
{#if item.label === selectedTab.label}
<motion.div class="underline" layoutId="underline" />
{/if}
</motion.li>
{/each} <script lang="ts">
import { AnimatePresence, motion } from "motion-sv";
const tabs = [
{ icon: "๐
", label: "Tomato" },
{ icon: "๐ฅฌ", label: "Lettuce" },
{ icon: "๐ง", label: "Cheese" },
];
let selectedTab = $state(tabs[0]);
</script>
<div class="container">
<nav class="nav">
<ul class="tabs-container">
{#each tabs as item (item.label)}
<motion.li
class="tab"
initial={false}
animate={{ backgroundColor: item.label === selectedTab.label ? "#eee" : "#eee0" }}
onclick={() => (selectedTab = item)}
>
{`${item.icon} ${item.label}`}
{#if item.label === selectedTab.label}
<motion.div class="underline" layoutId="underline" />
{/if}
</motion.li>
{/each}
</ul>
</nav>
<div class="icon-container">
<AnimatePresence mode="wait">
{#key selectedTab.label}
<motion.div
class="icon"
initial={{ y: 10, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: -10, opacity: 0 }}
transition={{ duration: 0.2 }}
>
{selectedTab.icon}
</motion.div>
{/key}
</AnimatePresence>
</div>
</div>
<style>
.container {
display: flex;
flex-direction: column;
width: 480px;
height: 360px;
max-width: calc(100% - 40px);
max-height: calc(100% - 40px);
border-radius: 10px;
background: white;
overflow: hidden;
box-shadow:
0 1px 1px hsl(0deg 0% 0% / 0.075),
0 2px 2px hsl(0deg 0% 0% / 0.075),
0 4px 4px hsl(0deg 0% 0% / 0.075),
0 8px 8px hsl(0deg 0% 0% / 0.075),
0 16px 16px hsl(0deg 0% 0% / 0.075),
0 2px 2px hsl(0deg 0% 0% / 0.075),
0 4px 4px hsl(0deg 0% 0% / 0.075),
0 8px 8px hsl(0deg 0% 0% / 0.075),
0 16px 16px hsl(0deg 0% 0% / 0.075);
}
.nav {
height: 44px;
padding: 5px 5px 0;
border-bottom: 1px solid #eeeeee;
border-radius: 10px 10px 0 0;
background: #fdfdfd;
}
.tabs-container {
display: flex;
width: 100%;
margin: 0;
padding: 0;
list-style: none;
font-size: 14px;
font-weight: 500;
}
.tabs-container :global(.tab) {
position: relative;
display: flex;
flex: 1;
justify-content: space-between;
align-items: center;
width: 100%;
min-width: 0;
height: 24px;
margin: 0;
padding: 10px 15px;
border-radius: 5px 5px 0 0;
background: white;
color: #0f1115;
font-size: 14px;
font-weight: 500;
list-style: none;
cursor: pointer;
user-select: none;
}
.tabs-container :global(.underline) {
position: absolute;
right: 0;
bottom: -2px;
left: 0;
height: 2px;
background: #ff0088;
}
.icon-container {
display: flex;
flex: 1;
justify-content: center;
align-items: center;
}
.icon-container :global(.icon) {
font-size: 128px;
}
</style> If the old element is still on the page when the new one enters, they crossfade from the old to the new. To animate back to the original element when the new one is removed, wrap it in AnimatePresence, which keeps it on the page until its exit has finished:
<motion.button layoutId="panel" onclick={() => (isOpen = true)}>Open</motion.button>
<AnimatePresence>
{#if isOpen}
<motion.div class="panel" layoutId="panel" onclick={() => (isOpen = false)} />
{/if}
</AnimatePresence> <script lang="ts">
import { AnimatePresence, motion } from "motion-sv";
let isOpen = $state(false);
</script>
<div class="stage">
<motion.button
class="open"
layoutId="panel"
style={{ borderRadius: 999 }}
transition={{ type: "spring", visualDuration: 0.3, bounce: 0.2 }}
onclick={() => (isOpen = true)}
>
Open
</motion.button>
<AnimatePresence>
{#if isOpen}
<motion.div
class="panel"
layoutId="panel"
style={{ borderRadius: 20 }}
transition={{ type: "spring", visualDuration: 0.3, bounce: 0.2 }}
onclick={() => (isOpen = false)}
>
Click to close
</motion.div>
{/if}
</AnimatePresence>
</div>
<style>
.stage {
position: relative;
display: grid;
place-items: center;
width: 420px;
height: 240px;
}
.stage :global(.open) {
padding: 10px 24px;
border: none;
background: #ff3e00;
color: white;
font: inherit;
font-weight: 600;
cursor: pointer;
}
.stage :global(.panel) {
position: absolute;
inset: 0;
display: grid;
place-items: center;
background: #ff3e00;
color: white;
font-weight: 600;
cursor: pointer;
}
</style> Setting a transition
Layout animations can be customised with the transition prop.
<layout.div transition={{ duration: 0.3 }} /> To set a transition for just the layout animation, use the layout key:
<layout.div
animate={{ opacity: 0.5 }}
transition={{
default: { ease: "linear" },
layout: { duration: 0.3 },
}}
/> A shared layout animation uses the transition of the element it's animating to:
<motion.button
layoutId="modal"
onclick={() => (isOpen = true)}
transition={{ type: "spring" }}
>
<!-- This transition is used when the modal closes -->
Open
</motion.button>
<AnimatePresence>
{#if isOpen}
<!-- This transition is used when the modal opens -->
<motion.dialog layoutId="modal" transition={{ duration: 0.3 }} />
{/if}
</AnimatePresence> Animating within a scrollable element
To animate layout correctly within a scrollable element, give it the layoutScroll prop. This lets Motion account for the element's scroll offset when measuring its children.
<motion.div layoutScroll style={{ overflow: "scroll" }}>
<!-- ...layout children -->
</motion.div> Animating within fixed containers
To animate layout correctly within a position: fixed element, give it the layoutRoot prop. This lets Motion account for the page's scroll offset when measuring its children.
<motion.div layoutRoot style={{ position: "fixed" }}>
<!-- ...layout children -->
</motion.div> Group layout animations
A namespace measures only its own elements. Elements in other components, with namespaces of their own, aren't measured, so when their layout changes as a result, they jump.
LayoutGroup links them: when any element inside it is measured, all of its layout elements are. Here each accordion item is a component with its own namespace, and opening one pushes the others down:
<script lang="ts">
import { LayoutGroup } from "motion-sv";
import AccordionItem from "./accordion-item.svelte";
</script>
<LayoutGroup>
<AccordionItem title="What is motion-sv?" />
<AccordionItem title="Do I need createLayoutMotion?" />
</LayoutGroup> <!-- accordion-item.svelte -->
<script lang="ts">
import { createLayoutMotion } from "motion-sv";
let { title, content } = $props();
let isOpen = $state(false);
const layout = createLayoutMotion(() => isOpen);
const toggle = () => (isOpen = !isOpen);
</script>
<layout.div onclick={toggle}>
<layout.h4 layout="position">{title}</layout.h4>
{#if isOpen}<p>{content}</p>{/if}
</layout.div> <script lang="ts">
import { LayoutGroup } from "motion-sv";
import AccordionItem from "./group/accordion-item.svelte";
</script>
<div class="accordion">
<LayoutGroup>
<AccordionItem title="What is motion-sv?" content="Motion's animation engine, ported to Svelte 5." />
<AccordionItem title="Do I need createLayoutMotion?" content="For elements that stay mounted and move, yes." />
<AccordionItem title="And for layoutId?" content="Swapping layoutId elements animates on its own." />
</LayoutGroup>
</div>
<style>
.accordion {
display: flex;
flex-direction: column;
gap: 8px;
width: 320px;
}
.accordion :global(.item) {
padding: 12px 16px;
background: rgb(255 62 0 / 0.15);
cursor: pointer;
}
.accordion :global(h4) {
margin: 0;
font-size: 15px;
}
.accordion :global(p) {
margin: 8px 0 0;
font-size: 14px;
opacity: 0.8;
}
</style> Without the LayoutGroup, the other items would jump to their new positions.
Scale correction
Layout animations use transform, which keeps them fast, but scaling an element can visually distort its children. To correct this, render the children through the namespace too. Layout children are scale-corrected.
Here, the pink dot is rendered through the namespace, so it keeps its shape while its parent resizes. Render it with motion.div instead, and the parent's scale distorts it:
<script lang="ts">
import { createLayoutMotion } from "motion-sv";
let isOpen = $state(false);
const layout = createLayoutMotion(() => isOpen);
const toggleOpen = () => (isOpen = !isOpen);
</script>
<div class="container">
<layout.div data-open={isOpen} initial={{ borderRadius: "50px" }} class="parent" onclick={toggleOpen}>
<layout.div data-open={isOpen} class="child" />
</layout.div>
</div>
<style>
.container {
display: flex;
justify-content: center;
align-items: center;
}
.container :global(.parent) {
display: flex;
justify-content: center;
align-items: center;
width: 100px;
height: 100px;
background-color: currentColor;
}
.container :global(.parent[data-open="true"]) {
width: 400px;
height: 200px;
}
.container :global(.child) {
width: 40px;
height: 40px;
border-radius: 50%;
background-color: #f107a3;
}
</style> Motion also corrects borderRadius and boxShadow distortion, as long as Motion sets them, like the parent's initial border radius above, rather than CSS. If you're not animating them, set them through style:
<layout.div style={{ borderRadius: 20 }} /> Troubleshooting
The element isn't animating
- Check the element is rendered through a layout namespace (
<layout.div>), not<motion.div>. - Check the namespace's dependency reads the state that changes the layout. If you update manually, check
layout.update()is called with the state change, in the same synchronous code (not after anawait). - Check the namespace is created in the component whose markup changes the layout, or in a parent of it, not in a child.
- Check the element isn't
display: inline. Browsers don't applytransformto inline elements. - If its layout changes because of another component's update, wrap both in a
LayoutGroupor share one namespace.
SVG layout animations are broken
SVG elements aren't supported with layout animations. SVGs don't have a layout system, so animate their attributes, like cx, directly instead.
The content stretches undesirably
This is a natural side effect of animating width and height with scale. It can often be fixed by rendering the children through the layout namespace too, so they're scale-corrected.
Elements that change aspect ratio, like images or text, often look better with layout="position", which animates only their position:
<layout.img layout="position" src={src} alt="" /> Border radius or box shadows behave strangely
Motion corrects scale distortion on borderRadius and boxShadow, but only when they're set through style:
<layout.div style={{ borderRadius: 10, boxShadow: "0 4px 12px rgb(0 0 0 / 0.2)" }} /> The border looks stretched during the animation
Elements with a border may look stretched during a layout animation, for two reasons:
- Changing
bordertriggers layout recalculations, which defeats the performance benefits of animatingtransform. You might as well animatewidthandheightclassically. bordercan't render thinner than1px, which limits how much Motion can correct it.
A workaround is to replace the border with a parent element whose padding acts as the border.
Differences with the View Transitions API
More browsers now support the View Transitions API, which is similar to layout animations.
View transitions are built into the browser, and they work by crossfading image snapshots of the old and new page. That makes simple crossfades cheap, and enables effects like full-screen wipes that layout animations aren't designed for.
They have some drawbacks compared to layout animations, though:
- Not interruptible. Interrupting a view transition snaps it to the end before the next one starts.
- Blocks interaction. The animating snapshots sit over the real page and block pointer events.
- One element per name. Layout animations allow more than one element with a
layoutId; view transitions break if the previous element isn't removed. - Less performant. View transitions animate
widthandheightof real screenshots, rather thantransform. - Doesn't account for scroll. If the page scrolls during a view transition, elements animate the scroll delta incorrectly.
- No relative animations. A delayed nested element gets left behind when its parent animates away.
- One animation at a time. View transitions animate the whole page, which makes combining them with other animations difficult.
Each system has its strengths. Use the one that fits the job.
Further reading
Nanda does an incredible job of explaining the challenges of animating layout with transforms, with interactive examples.