Layout and morph
A browser cannot animate a layout change. When a list reorders or a panel grows, the elements are in their new place on the next paint. FLIP is the standard way around that: do the change, then animate each element from where it used to be to where it is now, using only transform. damped does the bookkeeping and drives the transform with the same springs as everything else, so a layout animation that is interrupted keeps its velocity.
This guide covers layout(), snapshot() and measureLayout() first, then morph(), which builds a shared-element transition (a card that becomes a dialog) on the same machinery.
FLIP in three steps
Section titled “FLIP in three steps”FLIP stands for First, Last, Invert, Play. damped splits it into three moves:
- Measure. Record the box each element has now (its visual box, with any running transform) and the box it would have without a transform (its natural box).
- Invert. Let the DOM change, measure the new natural box, and write a transform that puts the element back on its old box. This happens before the browser paints, so the first frame still shows the old position.
- Play. Spring the transform back to the identity. The element glides from the old box to the new one.
The “invert” values are x, y, scaleX and scaleY, and they are ordinary animate() values. That is why interruption works: animating a layout again while it runs retargets those values and carries their velocity, like any other retarget. See Interruption and reversal.
layout()
Section titled “layout()”layout(target, mutate, options?) runs the three steps for you. Pass one element or an array, and a function that changes the DOM.
import { layout } from "@damped/core";
const list = document.querySelector<HTMLElement>("ul");const last = list?.querySelector<HTMLElement>("li:last-child");
if (list && last) { // Measure, run the callback, then animate from the old box to the new one. const controls = layout(last, () => list.prepend(last), { duration: 0.4, bounce: 0.1 }); await controls.finished;}Things worth knowing:
- Options are the spring options, plus
scheduler,reducedMotion,correctandradius.fromanddriverdo not apply to a layout. - Invalid options throw before
mutateruns and before any element is touched. - If an element did not move or resize by more than 0.01 px, nothing is written and no frame is requested.
- Layout animations always use the JS driver. A compositor animation that is running on the element is handed over to JS first, because the compositor’s progress is invisible to measurements.
When the change happens somewhere else
Section titled “When the change happens somewhere else”A framework often owns the DOM update, so you cannot wrap it in a callback. Take the snapshot yourself, let the framework commit, then animate:
import { snapshot } from "@damped/core";
export function animateChange(items: readonly HTMLElement[], commit: () => void): Promise<void> { const before = snapshot(items); // records the boxes now commit(); // the framework updates the DOM synchronously return before.animate({ duration: 0.4 }).finished;}snapshot() accepts an element or an array and returns an object with a single method, animate(options?). The options are validated before anything is measured or written.
measureLayout()
Section titled “measureLayout()”measureLayout(element) returns { x, y, width, height } in viewport coordinates: the box the element has without its inline transform. It clears the transform for the measurement and puts it back byte for byte, so it is safe to call while an animation runs. It forces a layout read, like any measurement.
import { measureLayout } from "@damped/core";
const card = document.querySelector<HTMLElement>(".card");if (card) { const box = measureLayout(card); console.log(`${box.width} x ${box.height} at ${box.x}, ${box.y}`);}Try it
Section titled “Try it”Press the buttons while the rows are still moving. Each row retargets from where it is, with the velocity it has.
Reorder a list
- Corner Grocery$64.20
- City Power$84.20
- FiberNet$59.90
- Blue Gym$32.00
- Harbor Insurance$118.50
Original order.
The demo wraps the state update in flushSync so React commits inside the layout() callback. The Demos page shows the code. In your own React code, prefer useLayout, described below.
Children and border radius
Section titled “Children and border radius”Scaling a box distorts what is inside it. A 200 px wide card that animates to 400 px wide with scaleX: 2 would stretch its text and flatten its rounded corners. damped corrects both while the animation runs.
| Option | What it does |
|---|---|
correct |
"children" or an array of elements. Each gets scale(1 / scaleX, 1 / scaleY) with transform-origin: 0 0, so it keeps its size while the parent scales. |
radius |
A border radius in px that stays visually constant. It is written as r / scaleX px / r / scaleY px during the animation and as r px at rest. |
import { layout } from "@damped/core";
const panel = document.querySelector<HTMLElement>(".panel");
if (panel) { layout(panel, () => panel.classList.toggle("expanded"), { duration: 0.45, correct: "children", radius: 16, });}"children"means the direct element children present when the animation starts. An explicit array corrects only those elements. Withoutcorrect, children are left alone.- Corrections are removed when the element settles at its natural box. If you call
stop()they stay where the animation froze, so the frozen frame does not snap. - Corrected children measured in a real browser stay within 1.5e-5 px of their true size during the animation (Chromium, the repository’s browser test).
morph()
Section titled “morph()”morph(from, to, options?) turns one element into another. Both stay in the DOM: to starts on the box of from and settles at its own box, and from travels onto the box of to. At the same time the two fade across each other, so it reads as one element changing shape.
What happens, frame by frame:
| Part | Behavior |
|---|---|
| Geometry | A FLIP for both elements, with the same scale and radius correction as layout. correct defaults to "children" here. |
| Crossfade | to fades in and from fades out. With the perceptual options the incoming fade runs twice as fast, so the combined opacity never dips in the middle (at least 0.85 asserted, 0.893 measured in Chromium). With physical options both fades use your spring, which may overshoot; the browser clamps opacity. Turn it off with crossfade: false. |
| Blur | The content children blur while they swap: incoming from blur px to 0, outgoing from 0 to blur px. The default is 8; 0 writes no filter at all. The two shells never get a filter, so their edges stay crisp. |
| Radius | With radius, corrected on both elements and written at rest on the one that ends on the identity. |
import { morph } from "@damped/core";
const options = { duration: 0.5, bounce: 0.1, radius: 16 } as const;
export function openBill(card: HTMLElement, dialog: HTMLElement): void { dialog.hidden = false; // visible and laid out at its final box before morph() measures it void morph(card, dialog, options); dialog.querySelector<HTMLElement>("button")?.focus({ preventScroll: true });}
export async function closeBill(card: HTMLElement, dialog: HTMLElement): Promise<void> { card.focus({ preventScroll: true }); // Reversal is the same call with the arguments swapped. const settled = await morph(dialog, card, options).finished; if (settled) dialog.hidden = true; // false means a newer morph took over: leave it visible}Preconditions
Section titled “Preconditions”- Both elements are visible and laid out at their final boxes when
morphis called. Ahiddenordisplay: noneelement has no box, so show it first. A zero box does not throw, but the geometry will be wrong. - The two elements must be different:
morph(a, a)throws aTypeError. - Invalid options or a non-finite measured box throw a
RangeErrorbefore anything is written. A morph that throws does not cut the one that is running. - The card and the dialog should each hold their content in direct children.
morphcorrects and blurs the direct children of each element, so one wrapper child per element is the easiest shape.
Reversal keeps the velocity
Section titled “Reversal keeps the velocity”Call morph(to, from) while the first one runs and both elements retarget the same values, so position and velocity carry over. In the Chromium test, a reversal triggered while the dialog is still opening keeps moving the same way for a moment (+37.0 px, then +27.2 px) before it turns back. A reversal that restarted from rest would barely move on the first step.
finished resolves true if this morph settled and false if a later morph or stop() on either element cut it short. It never rejects. stop() freezes both elements where they are and keeps the corrections applied.
City Power
$84.20
- Usage
- 312 kWh
- Period
- Jun 1 to Jun 30
- Due
- Jul 5
Press Escape or Close, even while it is still opening. It reverses from where it is.
Open the bill, then press Escape or Close while it is still opening. Focus goes into the dialog when the morph starts and back to the card as soon as a close starts, so Enter on the card reverses it.
Focus and accessibility
Section titled “Focus and accessibility”damped moves pixels. It does not manage focus and it does not add roles. A card that becomes a dialog needs the usual dialog behavior, and that is your job:
- Name and role. Give the dialog
role="dialog",aria-modal="true"and an accessible name (aria-labelledbyoraria-label). Give the cardaria-haspopup="dialog"andaria-expanded. - Focus in. Move focus into the dialog when the open starts, not when it settles; nobody should wait for a spring to reach the Close button.
- Focus trap and Escape. Keep Tab inside the dialog while it is open, and close on Escape. The
MorphCarddemo above and the bills view of the Northbook playground (apps/playground/src/BillCard.tsx) show a full implementation, including a portal todocument.body: a transformed ancestor becomes the containing block of a fixed dialog. - Focus back. Return focus to the card. Doing it when the close starts, not when it settles, lets Enter reverse the morph. The playground also marks the dialog
inertwhile it closes. - Closed means gone. A closed dialog must not be focusable or announced.
useMorphkeeps the targethiddenwhile closed. With the coremorph, sethiddenyourself once a close settles, as in the example above. - Reduced motion. Boxes and radius jump to their final values; the crossfade and the blur still animate, and
finishedstill resolvestrue. See Reduced motion.
In React
Section titled “In React”@damped/react wraps both APIs so you do not touch the DOM. Neither re-renders per animation frame.
useLayout
Section titled “useLayout”useLayout(deps, options?) returns a ref callback. When deps change (compared item by item with Object.is), the element animates from the box it had before the commit to its new box.
import { useState } from "react";import { useLayout } from "@damped/react";
function Row({ label, position }: { label: string; position: number }) { // The deps list holds whatever makes this row move. const ref = useLayout<HTMLLIElement>([position], { duration: 0.4, bounce: 0.1 }); return <li ref={ref}>{label}</li>;}
export function Bills({ names }: { names: string[] }) { const [reversed, setReversed] = useState(false); const ordered = reversed ? [...names].reverse() : names;
return ( <> <button type="button" onClick={() => setReversed((value) => !value)}> Reverse </button> <ul> {ordered.map((name, index) => ( <Row key={name} label={name} position={index} /> ))} </ul> </> );}- The first render never animates; there is no earlier box.
- The previous box is read during render, the last moment the DOM still shows the old layout. React may discard a render, which is harmless: each render replaces the snapshot, and only the commit of the render that took it animates.
- A deps change that does not move the element does not animate.
- StrictMode’s double render and effects produce a single, correct animation.
useMorph
Section titled “useMorph”useMorph(options?) returns { source, target, open, close, isOpen }. Attach source to the card and target to the dialog; both stay mounted.
import { useEffect, useRef, type KeyboardEvent } from "react";import { useMorph } from "@damped/react";
export function Bill() { const { source, target, open, close, isOpen } = useMorph({ duration: 0.5, bounce: 0.1, radius: 16 }); const card = useRef<HTMLButtonElement | null>(null); const closeButton = useRef<HTMLButtonElement>(null);
// Focus moves in as soon as the open starts. The target is already visible by now. useEffect(() => { if (isOpen) closeButton.current?.focus({ preventScroll: true }); }, [isOpen]);
const dismiss = () => { card.current?.focus({ preventScroll: true }); void close(); };
const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => { if (event.key === "Escape") { event.preventDefault(); dismiss(); } };
return ( <> <button type="button" ref={(node) => { card.current = node; source(node); }} aria-haspopup="dialog" aria-expanded={isOpen} onClick={() => void open()} > <span>City Power: $84.20</span> </button>
<div ref={target} role="dialog" aria-modal="true" aria-label="City Power bill" onKeyDown={onKeyDown}> <div> <p>Bill for John Doe</p> <button type="button" ref={closeButton} onClick={dismiss}> Close </button> </div> </div> </> );}Rules for the hook:
- Do not render a
hiddenprop on the target. The hook writes thehiddenattribute during the commit while closed, so it never flashes, andopen()removes it before measuring. - The geometry
morphmeasures must not depend onisOpen. It is read insideopen(), before React re-renders. Keep the dialog’s size and position the same whether it is open or not. - Author CSS that sets
displayon the target overrideshidden. Add[hidden] { display: none !important; }if yours does. isOpenbecomestruewhenopen()is called, not when the morph settles, and it is the only state. It never changes per frame.open()andclose()resolvetruewhen the morph settled andfalsewhen a later call or an unmount cut it. If either ref is not attached they resolvefalseat once.- Changing
optionstakes effect on the nextopen()orclose().
Edge cases
Section titled “Edge cases”- Elements that leave the DOM. Use
exit()or<Presence>for an element that is being removed; they keep it in the DOM until its animation settles. Do not pass an element tolayout()that your mutation removes: a detached element has no box to animate from or to. See Presence. - Scroll. Boxes are in viewport coordinates, so the page must not scroll between the snapshot and the point where the animation starts. A scroll in that window is read as movement of the element. Scrolling while the animation runs is fine, because the transform is relative to the element.
- Nested transforms. damped writes the deltas as local
translateandscalevalues. An ancestor that is scaled or rotated makes viewport deltas and local values differ, and damped does not compensate for it. Keep animated layout elements out of transformed ancestors, or put the transform on a sibling wrapper. - Your own transform. An animated element’s inline
transformbelongs to damped.measureLayoutclears and restores it, but a static transform you set will be replaced by the first animation. Put static transforms on a wrapper element. - Sub-pixel changes. A box that changes by 0.01 px or less is treated as unchanged: no write, no frame,
finishedresolves at once. - A zero-size axis. If an element has no width or height in its new layout, that axis keeps scale
1and the inverse scale stays finite.