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 { domMax, LazyMotion } from "motion/react"
import * as m from "motion/react-m"
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 (
    // This page lays out, so it brings `domMax`, Motion's layout and drag, itself: pages that don't never load it.
    <LazyMotion features={domMax}>
      <button className="sample-switch" role="switch" aria-checked={on} aria-label="Lights" onClick={() => setOn(!on)}>
        <m.span className="sample-knob" layout transition={{ type: "spring", stiffness: 500, damping: 30 }} />
      </button>
    </LazyMotion>
  )
}

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. To hear when the animations it started have ended:

  • On Solid 1, animateLayout returns what fn returns, and calls onSettled in its options: animateLayout(fn, { onSettled }).
  • On Solid 2, it returns a promise, which resolves then: await animateLayout(fn).

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 { domMax, LazyMotion } from "motion/react"
import * as m from "motion/react-m"
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 (
    // This page lays out, so it brings `domMax`, Motion's layout and drag, itself: pages that don't never load it.
    <LazyMotion features={domMax}>
      <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 && <m.span className="sample-underline" layoutId="underline" />}
          </button>
        ))}
      </div>
    </LazyMotion>
  )
}

<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, the page's under position: fixed, and how far a position: sticky element has stuck are accounted for on their own, with no layoutScroll or layoutRoot: a page scroll never reads as a move.

  • 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. <Reorder> brings it itself. <LayoutGroup> brings it only for its own layoutDependency, so the layout elements under it still need it from a provider or a LazyWardic. See Shipping less.