Docs/Guides/Layout animations

Layout animations

Set layout on a motion.* element to animate size or position changes. Motion measures the before and after boxes, then animates the difference.

<motion.div layout class={open() ? 'expanded' : 'collapsed'} />
layout
layout-basic.tsxOpen β†—
import { motion } from 'motion-solidjs'
import { createSignal } from 'solid-js'

export const meta = {
  slug: 'layout-basic',
  title: 'Layout β€” basic',
  category: 'layout',
  description: 'Toggle the alignment; layout="true" tweens the position.',
  tag: 'layout',
} as const

export default function LayoutBasic() {
  const [right, setRight] = createSignal(false)

  return (
    <button
      onClick={() => setRight((v) => !v)}
      class="flex h-12 w-56 items-center rounded-full border border-border bg-card p-1.5 data-[on=true]:justify-end"
      data-on={right()}
      style={{ 'justify-content': right() ? 'flex-end' : 'flex-start' }}
    >
      <motion.span
        data-state={right()}
        layout
        class="h-9 w-9 rounded-full bg-grad-rose shadow-glow"
        transition={{ type: 'spring', stiffness: 360, damping: 26 }}
      />
    </button>
  )
}

Shared layout

Give two motion elements the same layoutId to animate between them, even across components or routes. Pair it with <LayoutGroup> or <AnimatePresence> for tabs, expanding cards, and shared hero transitions.

<motion.span layoutId="active-pill" />
layoutId
πŸ…
shared-layout-tabs.tsxOpen β†—
import { AnimatePresence, motion } from 'motion-solidjs'
import { For, Show, createSignal } from 'solid-js'

export const meta = {
  slug: 'shared-layout-tabs',
  title: 'Shared layout β€” tabs',
  category: 'layout',
  description: 'layoutId slides the active underline; AnimatePresence swaps the body.',
  tag: 'layoutId',
} as const

const tabs = [
  { id: 'tomato', icon: 'πŸ…', label: 'Tomato' },
  { id: 'lettuce', icon: 'πŸ₯¬', label: 'Lettuce' },
  { id: 'cheese', icon: 'πŸ§€', label: 'Cheese' },
] as const

type TabId = (typeof tabs)[number]['id']

export default function SharedLayoutTabs() {
  const [active, setActive] = createSignal<TabId>('tomato')
  const selected = () => tabs.find((t) => t.id === active())!

  return (
    <div class="flex w-72 flex-col overflow-hidden rounded-2xl border border-border bg-card">
      <nav class="border-b border-border px-1.5 pt-1.5">
        <ul class="flex gap-1" style={{ 'list-style': 'none', padding: 0, margin: 0 }}>
          <For each={tabs}>
            {(t) => (
              <motion.li
                class="relative flex flex-1 cursor-pointer items-center justify-center gap-1.5 rounded-t-lg px-3 py-2 text-xs font-medium select-none"
                initial={false}
                animate={{
                  backgroundColor:
                    t.id === active() ? 'rgba(255,255,255,0.06)' : 'rgba(255,255,255,0)',
                }}
                onClick={() => setActive(t.id)}
              >
                <span>{t.icon}</span>
                <span>{t.label}</span>
                <Show when={t.id === active()}>
                  <motion.span
                    layoutId="shared-layout-tabs-underline"
                    class="absolute right-0 -bottom-px left-0 h-[2px] bg-grad-rose"
                    transition={{ type: 'spring', stiffness: 380, damping: 30 }}
                  />
                </Show>
              </motion.li>
            )}
          </For>
        </ul>
      </nav>
      <main class="flex h-32 items-center justify-center">
        <AnimatePresence mode="wait">
          <Show when={selected()} keyed>
            {(tab) => (
              <motion.div
                initial={{ y: 10, opacity: 0 }}
                animate={{ y: 0, opacity: 1 }}
                exit={{ y: -10, opacity: 0 }}
                transition={{ duration: 0.2 }}
                class="text-6xl"
              >
                {tab.icon}
              </motion.div>
            )}
          </Show>
        </AnimatePresence>
      </main>
    </div>
  )
}

Caveats

  • Layout animations need measurable ancestors; position: relative | absolute | fixed is safest.
  • A direct child of a layout element cannot itself be position: absolute; use motion.div with style={{ position: 'absolute' }} and layoutId instead.

See also: LayoutGroup, createInstantLayoutTransition.