Layout animations

layout animates an element from where it was to where the layout puts it now: a new size, a new place in a list, the other end of a flex row. The CSS makes the change at once, and Wardic plays it with transforms, so it stays smooth whatever changed.

Live demo
layout/solid2.tsx
import { layout, LazyWardic, w } from "@wardic/solid/unstable/v2"
import { createSignal } from "solid-js"

/** A switch: the stylesheet moves its knob across, and `layout` animates the move. */
export default function Switch() {
  const [on, setOn] = createSignal(false)
  return (
    // This page lays out, so it brings `layout` itself: pages that don't never load it.
    <LazyWardic features={[layout]}>
      <button class="sample-switch" role="switch" aria-checked={on() ? "true" : "false"} aria-label="Lights" onClick={() => setOn(!on())}>
        {/* Nothing re-renders in Solid: `layoutDependency` says when to look for a move. */}
        <w.span class="sample-knob" layout layoutDependency={on()} transition={{ type: "spring", stiffness: 500, damping: 30 }} />
      </button>
    </LazyWardic>
  )
}
layout/solid1.tsx
import { layout, LazyWardic, w } from "@wardic/solid"
import { createSignal } from "solid-js"

/** A switch: the stylesheet moves its knob across, and `layout` animates the move. */
export default function Switch() {
  const [on, setOn] = createSignal(false)
  return (
    // This page lays out, so it brings `layout` itself: pages that don't never load it.
    <LazyWardic features={[layout]}>
      <button class="sample-switch" role="switch" aria-checked={on() ? "true" : "false"} aria-label="Lights" onClick={() => setOn(!on())}>
        {/* Nothing re-renders in Solid: `layoutDependency` says when to look for a move. */}
        <w.span class="sample-knob" layout layoutDependency={on()} transition={{ type: "spring", stiffness: 500, damping: 30 }} />
      </button>
    </LazyWardic>
  )
}
layout/motion.tsx
import { motion } from "motion/react"
import { useState } from "react"

/** A switch: the stylesheet moves its knob across, and `layout` animates the move. */
export default function Switch() {
  const [on, setOn] = useState(false)
  return (
    <button className="sample-switch" role="switch" aria-checked={on} aria-label="Lights" onClick={() => setOn(!on)}>
      <motion.span className="sample-knob" layout transition={{ type: "spring", stiffness: 500, damping: 30 }} />
    </button>
  )
}

What starts one

Nothing re-renders in Solid, so Wardic doesn't look for a layout change after every update, as Motion does after every render. It measures at the end of a task in which:

  • an element with layout or a layoutId mounted;
  • one of their sizes changed, a child's or an ancestor's included;
  • a layoutDependency changed: give it whatever moves the element, as the switch above gives on();
  • the app made the change inside animateLayout(fn), from useAnimateLayout(). That's for a change nothing else sees, such as a plain list sorted:
const animateLayout = useAnimateLayout()
const sort = () => animateLayout(() => setItems(sorted(items())))

Each time it measures every layout element, so the ones a change pushes aside move too. animateLayout's promise resolves once the animations it started have ended.

Part of the change

layout="position" animates the move alone: the size changes at once. "size" animates the size alone, "x" and "y" one axis, and "preserve-aspect" the position unless the width-to-height ratio changes.

A layout animation stretches the element as it goes. border-radius and box-shadow are corrected for it, so a rounded card stays round, and a child with layout of its own isn't stretched with it.

One element, two places

Two elements with the same layoutId are one element in two places: as one mounts while the other leaves, it flies from where the other was.

Live demo
layout-id/solid2.tsx
import { layout, LazyWardic, w } from "@wardic/solid/unstable/v2"
import { createSignal, For, Show } from "solid-js"

const TABS = ["Bowls", "Plates", "Cups"]

/** Tabs with one underline: each tab's has the same `layoutId`, so it flies to the tab that's picked. */
export default function Tabs() {
  const [picked, setPicked] = createSignal("Bowls")
  return (
    <LazyWardic features={[layout]}>
      <div class="sample-tabs" role="tablist" aria-label="Shelves">
        <For each={TABS}>
          {(name) => (
            <button class="sample-tab" role="tab" aria-selected={picked() === name ? "true" : "false"} onClick={() => setPicked(name)}>
              {name}
              <Show when={picked() === name}>
                <w.span class="sample-underline" layoutId="underline" />
              </Show>
            </button>
          )}
        </For>
      </div>
    </LazyWardic>
  )
}
layout-id/solid1.tsx
import { layout, LazyWardic, w } from "@wardic/solid"
import { createSignal, For, Show } from "solid-js"

const TABS = ["Bowls", "Plates", "Cups"]

/** Tabs with one underline: each tab's has the same `layoutId`, so it flies to the tab that's picked. */
export default function Tabs() {
  const [picked, setPicked] = createSignal("Bowls")
  return (
    <LazyWardic features={[layout]}>
      <div class="sample-tabs" role="tablist" aria-label="Shelves">
        <For each={TABS}>
          {(name) => (
            <button class="sample-tab" role="tab" aria-selected={picked() === name ? "true" : "false"} onClick={() => setPicked(name)}>
              {name}
              <Show when={picked() === name}>
                <w.span class="sample-underline" layoutId="underline" />
              </Show>
            </button>
          )}
        </For>
      </div>
    </LazyWardic>
  )
}
layout-id/motion.tsx
import { motion } from "motion/react"
import { useState } from "react"

const TABS = ["Bowls", "Plates", "Cups"]

/** Tabs with one underline: each tab's has the same `layoutId`, so it flies to the tab that's picked. */
export default function Tabs() {
  const [picked, setPicked] = useState("Bowls")
  return (
    <div className="sample-tabs" role="tablist" aria-label="Shelves">
      {TABS.map((name) => (
        <button key={name} className="sample-tab" role="tab" aria-selected={picked === name} onClick={() => setPicked(name)}>
          {name}
          {picked === name && <motion.span className="sample-underline" layoutId="underline" />}
        </button>
      ))}
    </div>
  )
}

<LayoutGroup id="…"> puts an id before each layoutId made under it, so two copies of the same tabs keep their own underline.

Transitions and timing

A layout animation runs on transition's layout entry, else the element's transition, else Motion's 0.45 s tween. A path, such as arc(), curves a move of 20 px or more.

The scroll of every scrolling element around it, and the page's under position: fixed, is accounted for on its own, with no layoutScroll or layoutRoot. A position: sticky element is the exception: its place on the page changes as the page scrolls. Give it layoutRoot, as this site's sidebar has, so its own moves don't animate and the elements in it animate only as they move inside it.

  • On Solid 1, onLayoutAnimationStart and onLayoutAnimationComplete are called as one starts and lands.
  • On Solid 2, the element's ward gives layoutStart and layoutComplete: await card.next("layoutComplete").

The Feature

Layout animations are the layout Feature. A page that lays out brings it through <LazyWardic features={[layout]}>, as the demos do, so pages that don't never load it. <LayoutGroup> and <Reorder> bring it themselves.