Layout
Layout animation is FLIP: record where an element is, change the DOM, then spring the element from its old box to its new one. damped expresses the difference as x, y, scaleX and scaleY on the same values animate uses, so an interruption keeps position and velocity, and children and border radius can be kept undistorted while the parent scales.
Assumptions (not enforced): the element is not rotated, its scale is 1 and its transform-origin is the center. While a layout runs, it owns x, y, scaleX and scaleY of the element; animating them elsewhere in the meantime is unsupported. The transform origin of the element is pinned to 50% 50% once per run and restored at rest.
Layout needs a browser. It reads real boxes, so it never runs on a server, and a test environment without layout (happy-dom reports zeros) has to supply boxes.
Functions
Section titled “Functions”layout
Section titled “layout”Records the boxes of target, runs mutate, and animates every element from its previous box to its new one.
export function layout( target: Element | readonly Element[], mutate: () => void, options: LayoutOptions = {},): AnimationControls| Parameter | Type | Default | Description |
|---|---|---|---|
target |
Element | readonly Element[] |
none | The element or elements whose boxes are recorded. |
mutate |
() => void |
none | Changes the DOM synchronously: reorder, add a class, change a size. |
options |
LayoutOptions |
{} |
Spring, scheduler, reduced motion, child correction and radius. |
Returns AnimationControls, combined over all elements.
Behavior
- It validates the options first, before
mutateruns and before any element is touched. Then it takes asnapshot, callsmutate()and starts the animation. - For each element it measures the new natural box, computes the deltas that map it onto the recorded visual box (
xandyare center differences,scaleXandscaleYsize ratios),rebases the values to those deltas and animates them to the identity with the JS driver. - The inverse transform is written inline before
layoutreturns, not in the next frame. Even when the DOM change lands between two frames, for example in a promise callback, no paint shows the new box without it: the first rendered frame shows the element on its previous box. Nothing jumps. - A box that changes by
0.01px or less on every field counts as unchanged: nothing is written and no frame is requested, unless the element still has stale values from an earlier layout. - Interruption. Calling
layoutagain while elements move keeps their velocity: translation velocity is carried and scale velocity is rescaled to the new reference size. An interrupted layout still settles at the identity. - Reduced motion. The element jumps to its new box on the first frame, corrections are cleaned immediately and
finishedresolves. - Idle. Once everything settles, no frame is requested.
stop()freezes and keeps the corrections it applied.
Edge cases
- A
mutatethat throws propagates, and nothing is started. - A zero-size natural box keeps that axis at scale
1. - Invalid spring options throw a
RangeErrorbeforemutateruns. - Scale is clamped away from
0(at0.001) when computing inverse corrections, so a transient zero scale never producesInfinity.
layout is the one-call form. When the DOM change happens elsewhere, for example in a framework render, take the snapshot yourself.
Reorder a list
- Corner Grocery$64.20
- City Power$84.20
- FiberNet$59.90
- Blue Gym$32.00
- Harbor Insurance$118.50
Original order.
Example
import { layout } from "@damped/core";
const list = document.querySelector<HTMLElement>("ul");const last = list?.querySelector<HTMLElement>("li:last-child");
if (list !== null && list !== undefined && last !== null && last !== undefined) { // Snapshot, mutate, then animate from the previous box to the new one. const controls = layout(last, () => list.prepend(last), { duration: 0.4, bounce: 0.1 }); await controls.finished;}See also: snapshot, LayoutOptions, useLayout, morph, Layout and morph, Demos
snapshot
Section titled “snapshot”Records the current boxes of target so you can animate from them after you change the DOM yourself.
export function snapshot(target: Element | readonly Element[]): LayoutSnapshot| Parameter | Type | Default | Description |
|---|---|---|---|
target |
Element | readonly Element[] |
none | The element or elements to record. |
Returns a LayoutSnapshot.
Behavior
- For each element it records the current visual box (the running transform included), the natural box and the velocities of
x,y,scaleXandscaleYof its existing values. - A running compositor animation is first handed to the JS values at its exact state, so the measurements are correct.
- It reads layout, so it forces a style recalculation. Take it as late as possible: after the last write you make and before the DOM change.
Edge cases
- Nothing is validated until you call
animate(). - Elements animate from where they appear to be, so a snapshot taken while an element is mid-flight records the visual box including the running transform.
Example
import { snapshot } from "@damped/core";
const list = document.querySelector<HTMLElement>("ul");
if (list !== null) { const items = [...list.querySelectorAll<HTMLElement>("li")]; const before = snapshot(items);
// A framework render or any other DOM change goes here. for (const item of items.reverse()) list.append(item);
const controls = before.animate({ duration: 0.4, correct: "children", radius: 12 }); await controls.finished;}See also: LayoutSnapshot, layout, measureLayout
measureLayout
Section titled “measureLayout”Returns the natural layout box of an element in viewport coordinates, ignoring any transform damped is currently applying.
export function measureLayout(element: Element): Box| Parameter | Type | Default | Description |
|---|---|---|---|
element |
Element |
none | The element to measure. |
Returns a Box.
Behavior
- It is
getBoundingClientRect()with the element’s inlinetransformcleared for the measurement and restored byte for byte afterwards, even if the measurement throws. - It skips the style writes when the transform is empty,
"none"or the identity string, because those cannot change the box. - It forces a layout read.
Edge cases
- It needs a real layout engine. In happy-dom
getBoundingClientRect()reports zeros. - An element without
style(a non-styleable node) is measured without any write.
Example
import { animate, measureLayout } from "@damped/core";
const badge = document.querySelector<HTMLElement>(".badge");
if (badge !== null) { void animate(badge, { x: 120 }); // moving... const natural = measureLayout(badge); // ...but this is where it lives in the layout console.log(natural.x, natural.y, natural.width, natural.height);}See also: Box, snapshot, layout
A rectangle in viewport coordinates.
export interface Box { x: number; y: number; width: number; height: number;}| Field | Type | Default | Description |
|---|---|---|---|
x |
number |
none | Left edge, in px. |
y |
number |
none | Top edge, in px. |
width |
number |
none | Width in px. |
height |
number |
none | Height in px. |
Behavior
xandyare the left and top ofgetBoundingClientRect(), in viewport coordinates, not relative to the page or to a parent.
Example
import { measureLayout, type Box } from "@damped/core";
export function center(element: Element): { x: number; y: number } { const box: Box = measureLayout(element); return { x: box.x + box.width / 2, y: box.y + box.height / 2 };}See also: measureLayout, snapshot
LayoutOptions
Section titled “LayoutOptions”The options of layout and LayoutSnapshot.animate: the spring, scheduler and reduced motion options of animate, plus child correction and radius.
export type LayoutOptions = DistributiveOmit<AnimateOptions, "from" | "driver"> & { correct?: readonly HTMLElement[] | "children"; radius?: number;};| Option | Type | Default | Description |
|---|---|---|---|
| spring options | SpringOptions |
duration: 0.5, bounce: 0.15 |
Either form. Validated first. |
scheduler |
Scheduler |
the shared frame |
Fixed per element by the first call. |
reducedMotion |
"user" | "always" | "never" |
"user" |
The element jumps to its new box when reduced motion is on. |
correct |
readonly HTMLElement[] | "children" |
none: children are left alone | Children whose size must not distort while the parent scales. Each gets transform: scale(1/sx, 1/sy) with transform-origin: 0 0, written in the same job as the parent. "children" resolves, when animate() runs, to the direct element children that have a style. An explicit list corrects only those. |
radius |
number |
none | Border radius in px kept visually constant while the element scales. Written as ${r/sx}px / ${r/sy}px while scaling and ${r}px at rest. Finite and >= 0, otherwise a RangeError. |
Behavior
fromanddriverare removed from the type: a layout would ignore them. Both spring option forms stay available.morphextends this type and defaultscorrectto"children".
Example
import { layout, type LayoutOptions } from "@damped/core";
const options: LayoutOptions = { duration: 0.45, bounce: 0.1, correct: "children", radius: 16 };
const card = document.querySelector<HTMLElement>(".card");if (card !== null) { void layout(card, () => card.classList.toggle("expanded"), options);}See also: layout, LayoutSnapshot, AnimateOptions, MorphOptions
LayoutSnapshot
Section titled “LayoutSnapshot”The recorded boxes returned by snapshot, with one method to animate from them.
export interface LayoutSnapshot { /** Animates every recorded element from its recorded visual box to its current natural layout. */ animate(options?: LayoutOptions): AnimationControls;}| Member | Type | Description |
|---|---|---|
animate(options?) |
(options?: LayoutOptions) => AnimationControls |
Animates every recorded element from its recorded box to its current natural layout. |
Behavior
animatevalidates the options before it measures or writes anything.- Call it after the DOM change. Calling it without a change is a no-op for elements whose box did not move.
Example
import { snapshot, type LayoutSnapshot } from "@damped/core";
const panel = document.querySelector<HTMLElement>(".panel");
if (panel !== null) { const before: LayoutSnapshot = snapshot(panel); panel.classList.add("wide"); void before.animate({ duration: 0.4, correct: "children" });}See also: snapshot, layout, LayoutOptions, AnimationControls