Layout Animations
Svelte Motion supports two kinds of layout animation:
layout— animate a single element when its own size or position changes (FLIP).layoutId— animate between two different elements that share the same identifier. When one unmounts and another with the samelayoutIdmounts, the new element FLIP-animates from the old one’s position.
Single-element layout animation
Add layout to any motion element to have it automatically animate when its position or size changes in the DOM.
<motion.div layout>
<!-- This element will FLIP-animate whenever its bounding rect changes -->
</motion.div><motion.div layout>
<!-- This element will FLIP-animate whenever its bounding rect changes -->
</motion.div>Use layout="position" to only animate translation (no scale):
<motion.div layout="position">
<!-- Translates smoothly, does not scale -->
</motion.div><motion.div layout="position">
<!-- Translates smoothly, does not scale -->
</motion.div>Shared layout with layoutId
The layoutId prop enables shared layout animations between completely different elements. When element A with layoutId="foo" unmounts and element B with the same layoutId="foo" mounts, B automatically animates from A’s last known position.
Tab underline example
The most common use case is a tab indicator that slides between tabs:
<script lang="ts">
import { AnimatePresence, motion } from '@humanspeak/svelte-motion'
let selectedTab = $state(0)
const tabs = ['Home', 'About', 'Contact']
</script>
{#each tabs as tab, i (tab)}
<button onclick={() => (selectedTab = i)}>
{tab}
<AnimatePresence>
{#if selectedTab === i}
<motion.div
key="underline"
layoutId="underline"
style="position: absolute; bottom: 0; left: 0; right: 0; height: 2px; background: royalblue;"
transition={{ type: 'spring', stiffness: 500, damping: 30 }}
/>
{/if}
</AnimatePresence>
</button>
{/each}<script lang="ts">
import { AnimatePresence, motion } from '@humanspeak/svelte-motion'
let selectedTab = $state(0)
const tabs = ['Home', 'About', 'Contact']
</script>
{#each tabs as tab, i (tab)}
<button onclick={() => (selectedTab = i)}>
{tab}
<AnimatePresence>
{#if selectedTab === i}
<motion.div
key="underline"
layoutId="underline"
style="position: absolute; bottom: 0; left: 0; right: 0; height: 2px; background: royalblue;"
transition={{ type: 'spring', stiffness: 500, damping: 30 }}
/>
{/if}
</AnimatePresence>
</button>
{/each}How it works
- Only one element with a given
layoutIdshould be mounted at a time (use conditional rendering). - When the old element unmounts, its bounding rect is captured.
- When the new element mounts, it reads the stored rect and performs a FLIP animation from the old position to its own natural position.
- The animation uses the element’s
transitionprop, or falls back to the previous element’s transition.
Full interactive example
Inside scroll containers — layoutScroll
By default, FLIP measures elements using getBoundingClientRect(), which returns viewport-relative coordinates. If a motion.div with layout lives inside an overflow: scroll container and the user scrolls during the animation, the scroll offset shows up as an unwanted translate — the box visibly drifts.
The fix is to mark the scroll container with layoutScroll. Descendant layout animations then measure rects in that container’s coordinate space, so a mid-animation scroll cancels out instead of leaking into the FLIP delta.
<motion.div layoutScroll style="overflow: auto; height: 320px;">
<motion.div layout>Resize me — scroll the container mid-animation</motion.div>
</motion.div><motion.div layoutScroll style="overflow: auto; height: 320px;">
<motion.div layout>Resize me — scroll the container mid-animation</motion.div>
</motion.div>Apply layoutScroll on the same motion.* element that owns the scrolling — the same element you’d set overflow: scroll (or auto) on. Nested layoutScroll containers stack: a layout descendant accounts for the scroll offset of every layoutScroll ancestor in its path, so you can wrap a scrollable list inside a scrollable panel and both contribute to the FLIP measurement.
| Prop | Type | Description |
|---|---|---|
layoutScroll | boolean | Mark this element as a scroll container so descendant layout measurements account for its scroll offset. Nested layoutScroll ancestors stack. |
Scoping shared animations — <LayoutGroup>
If two regions of your UI reuse the same layoutId values (think two tab strips both using layoutId="underline", or several Kanban columns each with a layoutId="indicator"), they’d cross-animate by default — the global registry doesn’t know which instance is which, and an unmount in one region can be picked up by a mount in another.
Wrap each region in <LayoutGroup id="…"> to scope the registry to that subtree:
<script>
import { AnimatePresence, LayoutGroup, motion } from '@humanspeak/svelte-motion'
</script>
<LayoutGroup id="strip-a">
<Tabs />
</LayoutGroup>
<LayoutGroup id="strip-b">
<Tabs />
<!-- same layoutId values inside — but independent of strip-a -->
</LayoutGroup><script>
import { AnimatePresence, LayoutGroup, motion } from '@humanspeak/svelte-motion'
</script>
<LayoutGroup id="strip-a">
<Tabs />
</LayoutGroup>
<LayoutGroup id="strip-b">
<Tabs />
<!-- same layoutId values inside — but independent of strip-a -->
</LayoutGroup>Nested groups chain by default (inherit={true}), so a <LayoutGroup id="inner"> inside a <LayoutGroup id="outer"> yields an effective id of "outer-inner". Pass inherit={false} to start a fresh scope that ignores any surrounding group — useful for embedded widgets that should not be affected by ambient grouping.
| Prop | Type | Description |
|---|---|---|
id | string? | Stable identifier for this group. Combined with any surrounding LayoutGroup’s id when inherit is true. |
inherit | boolean \| 'id' (default true) | When true (or 'id'), chain onto the parent group’s id. When false, ignore any outer group and start a fresh scope. 'id' is accepted for drop-in compatibility with framer-motion examples; it behaves the same as true here. |
API reference
layout
| Value | Behavior |
|---|---|
true | Animate translate + scale on layout change |
"position" | Animate translate only (no scale) |
false / omitted | No layout animation |
layoutId
| Prop | Type | Description |
|---|---|---|
layoutId | string | Shared identifier. Elements with matching layoutId animate between each other’s positions. |
layoutId works best inside AnimatePresence, which provides the registry that coordinates the snapshot/consume handoff.
Related
- AnimatePresence — Coordinate enter/exit animations
- Shared Layout Animation example — Full interactive demo
- LayoutGroup example — Two sibling tab strips reusing the same
layoutIdwithout crossing over