logo svelte /motion v0.5.1

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.

Full interactive example

mode · live running open
🍅

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.

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>

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.

PropTypeDescription
idstring?Stable identifier for this group. Combined with any surrounding LayoutGroup’s id when inherit is true.
inheritboolean \| '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

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