Morphing
A surface that changes what it shows eases to its new size while its contents crossfade inside, so the eye follows one object instead of watching one leave and another arrive.
One object, not two#
A pill turns into a card, a rail into a menu, a dialog moves to a taller step. The easy version swaps one box for another in a single frame: everything around it jumps, and the reader has to find their place again. A morph keeps one box on screen and eases it to the new size while the contents crossfade in place, so the change reads as the same object doing something new. Run the export in both modes.
The box growing is also the entrance. Nothing inside it needs to slide or stagger in, because the continuity is already carried by the one surface that never left.
Measure where it’s going#
The box never measures itself. It measures an inner viewport that is free to be its natural size. The moment the contents change, the viewport is already at the new size while the box is still at the old one, so a ResizeObserver on the viewport reads where the box is going rather than where it is. Written back as an inline width and height, that gives CSS two real numbers to tween between, and the clip on the box reveals the new view as it grows around it. Watch the dashed outline land first.
useMorphBox from lib/morph.ts is the whole technique: one hook and one class list. Spread morphBox and the returned style on the box, put the ref and viewportClassName on the viewport, and feed ready to data-morph-ready.
import { morphBox, useMorphBox } from "@/lib/morph"
export function WizardBody({ step }: { step: React.ReactNode }) {
// Destructure: reading morph.style off the object trips react-hooks/refs.
const { ref, style, ready, viewportClassName } = useMorphBox("height")
return (
// The box: eases to whatever the viewport measures, and clips so it reads as one object.
<div data-morph-ready={ready ? "" : undefined} style={style} className={cn(morphBox)}>
{/* The viewport: its natural size, already at the next step. */}
<div ref={ref} className={viewportClassName}>
{step}
</div>
</div>
)
}Two details inside the hook are worth knowing. It reads the layout box (offsetWidth, offsetHeight), never a rect, so a press-scale on a child, which is a transform, can never feed jitter back into the size. And it takes a callback ref rather than a ref and a mount effect, because the measured node usually arrives later than the component that owns the hook: a dialog’s content, a menu’s popper, a panel behind a flag.
Pick the axis#
Tell the hook which dimensions the box owns. The viewport’s sizing follows from it, which is why viewportClassName comes back with the rest: a box that owns its width needs a viewport free to be wider than it is, and one that only owns its height needs a viewport that fills it.
"height"
w-full
Tabs, dialogs
"both"
w-max
Island, Dock
"width"
w-max
Inline chips
The shape rides the size#
When a box changes shape because it changes size, keep one radius and let the geometry do the work. Any radius past half the height is clamped to exactly half, so rounded-2xl is a perfect stadium on a 44px row and a card’s corner once the box grows. Tweening rounded-pill into rounded-2xl instead looks broken: the pill radius stays past half the height for nearly the whole run, so the box balloons into a capsule and only squares off in the last frames. Both end states are identical; play them and watch the corners in between.
rounded-2xl
One radius at both sizes
rounded-pill → rounded-2xl
Two radii, tweened
When the corner has to change on purpose, derive it from the size the box is going to instead of switching classes. Island writes its measured height to a variable and resolves the corner with one formula: half the height while it is one row, capped at radius-4xl once it grows into a card, and on the radius knob throughout. The corner then eases with the box, between two values that are both real.
// ✓ One radius: clamped to a stadium while short, a card's corner once tall.
<div className="rounded-2xl transition-[width,height] duration-base ease-out" />
// ✓ A corner that must change, derived from the height it is going to (Island).
<div
style={{ "--island-height": `${height}px` }}
className="rounded-[min(var(--radius-pill),calc(var(--island-height)/2),var(--radius-4xl))]
transition-[width,height,border-radius] duration-fast ease-out"
/>
// ✗ Two classes tweened: a capsule for most of the run, then a snap.
<div className={cn("transition-[width,height,border-radius]", open ? "rounded-2xl" : "rounded-pill")} />Contents crossfade in place#
The box carries the movement, so the contents should not travel. The view on screen blurs in (fade-in-0, zoom-in-96, blur-in-4) while the view leaving is lifted out of flow, pinned to the centre where it was, and blurs out over the same beat. Lifting it out is what lets the viewport measure the incoming view at once; left in flow, it would hold the box at the old size until its exit had played.
Run the box and the crossfade on one clock. A box that lands before its contents, or contents still settling in a box that has stopped, read as two animations. Island runs both on duration-fast; the Dock, whose panel is larger, on duration-base. When the change has a direction, drilling into a layer and back out, say which way with two pixels of slide on the incoming layer only.
// The view on screen: blurs in on the box's clock.
"data-[state=enter]:animate-in data-[state=enter]:fade-in-0 data-[state=enter]:zoom-in-96 data-[state=enter]:blur-in-4",
// The view leaving: out of flow and centred, so the viewport already measures the next one.
"data-[state=exit]:absolute data-[state=exit]:top-1/2 data-[state=exit]:left-1/2 data-[state=exit]:w-max",
"data-[state=exit]:-translate-x-1/2 data-[state=exit]:-translate-y-1/2",
"data-[state=exit]:animate-out data-[state=exit]:fade-out-0 data-[state=exit]:zoom-out-98",
"data-[state=exit]:blur-out-4 data-[state=exit]:fill-mode-forwards",
// Keyframes only. A duration with no property list transitions `all`, and the
// leaving view would slide into its centring translate instead of taking it.
"transition-none duration-base ease-out motion-reduce:animate-none",
// Layers with a direction (Dock): in from the right going deeper, from the left coming back.
"data-[direction=forward]:slide-in-from-right-2 data-[direction=backward]:slide-in-from-left-2",The leaving view is inert and aria-hidden, and it usually holds the control that was just pressed (Back, Dismiss). Hand focus to the first control on screen before the browser drops it on the body, and announce the new state in a polite live region if the view that arrives has nothing to focus.
Nothing morphs on load#
A surface that is on screen at first paint was not asked to change, so it should not grow in from zero. morphBox only transitions under data-morph-ready, which the hook sets once a measurement exists: the server paints the natural size, the first measurement lands without a tween, and only the changes after it ease. Arm the crossfade the same way, on the first change of view.
When the measured node leaves, the hook drops its size with it, so a dialog that closes and opens again starts from its own content instead of easing down from whatever the last one happened to be. Under reduced motion morphBox stands down (motion-reduce:transition-none): the box takes the new size at once and the leaving view goes instead of blurring out. The change still happens; it just stops moving.
Where not to morph#
It animates layout. Width and height re-run layout every frame, for the box and whatever flows around it. That is cheap on a pill, a menu or a dialog body and expensive on a full-page panel. A drawer or a sheet still moves with translate.
The clip is the point, and it cuts. overflow-hidden is what makes the morph read as one object, and it also clips focus rings and anything not portalled. Leave the box room for a ring, draw rings inset the way TabsPanels does, or pad the viewport and give the space back with a negative margin, the way Island’s swap regions do (p-1 with -m-1).
Content without a ceiling turns it back into a jump. Past a certain height the ease stops reading as movement and starts reading as lag. Cap the box with a max height and scroll inside it.
Don’t cascade inside it. Rows that stagger up through a growing box fight the box. The morph is the entrance, so let the rows arrive with it.
In the library#
- Island
A floating pill whose views crossfade while it eases to each one’s size, corner included.
- Dock
A rail of icons that opens, in place, into a labelled menu with drill-down layers.
- Tabs
TabsPanelseases the block’s height between panels of different lengths. - Animated Label
The label rolls to its new copy while its box eases between the two widths.
- Button
swapKeyhands the label to Animated Label, so Next to Done morphs instead of jumping. - Carousel
The active dot grows into a pill:
rounded-fullrides the width, one radius the whole way.