One component, three modes. It renders one <path> per slot and then gets out of the way:
frames are written straight to the DOM by the driver, outside React's render, so a morph
costs zero re-renders however long it runs.
Ninety percent of uses. Change the prop and it animates.
import { useState } from "react";
import { MorphIcon } from "@/lib/morph/react";
import { coreBundle } from "@/lib/morphs.generated";
export function Uncontrolled() {
const [open, setOpen] = useState(false);
return (
<button type="button" onClick={() => setOpen((o) => !o)} aria-expanded={open}>
{/* Change the prop. The component animates and never re-renders per frame. */}
<MorphIcon bundle={coreBundle} icon={open ? "cross" : "menu"} motion="snappy" />
</button>
);
}For when progress belongs to something else: a drag, a scroll position, a gesture. No tween runs and nothing is animated for you.
import { MorphIcon } from "@/lib/morph/react";
import { coreBundle } from "@/lib/morphs.generated";
export function Controlled({ drag }: { drag: number }) {
// While `from` and `to` are both present the pair owns the path, nothing animates, and
// `icon` is ignored. Progress is yours to drive.
return <MorphIcon bundle={coreBundle} from="menu" to="cross" progress={drag} />;
}While from and to are both present the pair owns the path and icon is ignored. Drop
the pair and icon takes over, animated. Mixing them is not an error, the precedence is
just explicit.
For sequences, or for driving from an event handler rather than from state.
import { useRef } from "react";
import { type MorphHandle, MorphIcon } from "@/lib/morph/react";
import { coreBundle } from "@/lib/morphs.generated";
export function Imperative() {
const ref = useRef<MorphHandle>(null);
return (
<>
<MorphIcon ref={ref} bundle={coreBundle} icon="menu" label="Menu" />
<button type="button" onClick={() => ref.current?.morphTo("cross")}>
close
</button>
</>
);
}Three rules, shared by every binding and pinned by tests, because getting them subtly different per framework is the obvious failure:
from.The server emits the exact resting geometry from the bundle, so there is no blank frame and nothing for hydration to reconcile. The driver takes over on mount.