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.
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>
)
}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>
)
}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
layoutor alayoutIdmounted; - one of their sizes changed, a child's or an ancestor's included;
- a
layoutDependencychanged: give it whatever moves the element, as the switch above giveson(); - the app made the change inside
animateLayout(fn), fromuseAnimateLayout(). 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,
animateLayoutreturns whatfnreturns, and callsonSettledin 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.
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>
)
}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>
)
}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,
onLayoutAnimationStartandonLayoutAnimationCompleteare called as one starts and lands. - On Solid 2, the element's ward gives
layoutStartandlayoutComplete: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.