logo svelte /motion v2.0.1-0

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 same layoutId mounts, 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

  1. Only one element with a given layoutId should be mounted at a time (use conditional rendering).
  2. When the old element unmounts, its bounding rect is captured.
  3. When the new element mounts, it reads the stored rect and performs a FLIP animation from the old position to its own natural position.
  4. The animation uses the element’s transition prop, or falls back to the previous element’s transition.

Layout animations can travel along a curve instead of a straight FLIP interpolation. See arc() for transition.layout.path and shared layoutId examples.

Full interactive example

mode · live running open
// shared-layout tab: tomato
🍅
layoutId: underline mode: wait

Scrolling and layout animations

Layout measurements are taken in page space: the element’s position in the document, independent of how far the window is scrolled. Scrolling the page between two layout changes — or while a layout animation is playing — can never masquerade as a layout delta, so layout animations run identically whether or not the user has scrolled. This also applies to elements that change layout while scrolled offscreen: they animate to their new position like any other element instead of snapping.

Inside scroll containers — layoutScroll

Page-space measurement accounts for window scrolling automatically, but an overflow: scroll container’s internal scroll offset is invisible to it. If a motion.div with layout lives inside such a container and the user scrolls it during the animation, the container’s 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 container 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.

PropTypeDescription
layoutScrollbooleanMark 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>

A <LayoutGroup> also groups layout animations, like Motion’s: every layout / layoutId element inside it is re-measured whenever any member changes layout or unmounts, so siblings animate together. Remove an item from a list and the remaining items glide into the freed space, even though only the removed item changed.

Nested groups chain by default (inherit={true}), so a <LayoutGroup id="inner"> inside a <LayoutGroup id="outer"> yields an effective id of "outer-inner" and joins the outer group’s animations. inherit="id" chains the id but starts a separate group: the outer group’s updates don’t re-measure those elements, which ride along with their parent instead of animating on their own. Pass inherit={false} to start a fresh scope and a separate group that ignore any surrounding LayoutGroup — useful for embedded widgets that should not be affected by ambient grouping.

PropTypeDescription
idstring?Stable identifier for this group. Combined with any surrounding LayoutGroup’s id when inherit is true.
inheritboolean \| 'id' (default true)true: chain onto the parent group’s id and join its layout-animation group. 'id': chain onto the parent’s id but start a separate group, so the outer group’s updates don’t re-measure these elements (they follow their parent instead). false: ignore any outer group; fresh id scope and separate group.

API reference

layout

ValueBehavior
trueAnimate translate + scale on layout change
"position"Animate translate only (no scale)
false / omittedNo layout animation

layoutId

PropTypeDescription
layoutIdstringShared 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