Morph

One element flows into another and back, on springs you can turn around mid-way. Any element; with Liquid Blur, the same glass.

Press the button, or drag it to a corner. Press again mid-way: it turns around.

On this page

Get started

A morph flows between states: real elements, each laid out where and as it is at rest. One is shown, the others are hidden. Asked for another, a single shape flows from the box of the one shown to the box of the other, and at rest that element is itself again. Here the states are a button and its panel.

  1. Install.

    pnpm add @fluidkit/morph
  2. Lay out both states where they are at rest. Hide the panel with visibility: hidden so it can still be measured, and put it above the button (z-index: 2). Each state has its own surface: here both are Liquid Blur glass, but any styling works.

    <div class="morph-scene">
      <button id="more" class="lb" type="button" aria-controls="panel" aria-expanded="false">More</button>
      <div id="panel" class="lb">
        <p>Panel content</p>
        <button id="close-panel" type="button">Close</button>
      </div>
    </div>
    
    <style>
      .morph-scene { position: relative; min-height: 240px; }
      #more { width: 88px; height: 44px; border-radius: 22px; }
      #panel {
        position: absolute;
        left: 0;
        top: 0;
        width: min(280px, 100%);
        height: 220px;
        box-sizing: border-box;
        padding: 20px;
        border-radius: 28px;
        visibility: hidden;
        z-index: 2;
      }
    </style>
  3. Create the morph after the elements are in the DOM, and send it to a state from your own handlers. What a menu needs on top (its aria-expanded, Escape, focus) is yours, in onStart and onRest.

    import "@fluidkit/blur/style.css"; // the glass, if the states are glass
    import { createMorph } from "@fluidkit/morph";
    
    const button = document.getElementById("more")!;
    const panel = document.getElementById("panel")!;
    const close = document.getElementById("close-panel")!;
    
    const morph = createMorph({
      states: [button, panel],
      onStart(to) {
        button.setAttribute("aria-expanded", String(to === panel));
        // The panel is itself on its way in: focus can go into it at once
        if (to === panel) close.focus({ preventScroll: true });
      },
      onRest(at) {
        if (at === button && panel.contains(document.activeElement)) button.focus({ preventScroll: true });
      },
    });
    
    const toggle = () => morph.to(morph.current === panel ? button : panel);
    const back = () => morph.to(button);
    const onKey = (e: KeyboardEvent) => e.key === "Escape" && back();
    button.addEventListener("click", toggle);
    close.addEventListener("click", back);
    document.addEventListener("keydown", onKey);
    
    // On unmount:
    // button.removeEventListener("click", toggle);
    // close.removeEventListener("click", back);
    // document.removeEventListener("keydown", onKey);
    // morph.destroy();

Examples

The shape takes the surface of the states, so the result depends on what they’re made of.

Plain CSS

Without Liquid Blur: a solid pill flows into a solid panel.

From solid to glass

The pill is glass covered by a solid layer, the panel plain glass. The two surfaces differ, so on the way the pill’s dissolves into the panel’s: nothing to script.

In a glass group

A button in a merged toolbar. The panel opens over the neighboring buttons, so the shape leaves the group and flies as separate glass. See With Liquid Blur.

Shared elements

Mark an element in each of two states with the same data-fk-match, and it flies from where it is in one to where it is in the other, crossing over from one to the other on the way. Here the pill’s icon flies into the menu’s title, and its label into the option picked. Pick another and the pill comes back with it.

In the smaller state it stays where it is; in the bigger one it keeps to the edge it’s nearest (or the middle), so a label by the left edge stays by it however the shape grows. Its size changes with the shape’s, keeping its proportions. Move the attribute when what’s shared changes, as the pick does here: the morph reads it each time it sets off or turns around.

More than two states

A morph takes any number of states, and flows from whichever is shown to whichever is asked for. Here a button grows into a bar, and the bar into a card; the tabs jump straight between any two. The play button is shared by all three, the artwork and the title by the bar and the card: a match flies only between two states that both have it.

Each flight is between two states, the smaller riding as a copy and the bigger flying itself. Sent back mid-way, it turns around; sent to the third, it starts over from where the shape is.

Options

createMorph(options: MorphOptions): Morph

Option Meaning
states: HTMLElement[] The elements it flows between, each with a parent in the DOM, laid out where it is at rest
initial?: HTMLElement The one shown at the start; the first state by default
container?: HTMLElement Where to put the shape instead of next to the smaller state. Use it when that state’s parent has overflow: hidden
spring?: { grow?, shrink? } Springs for flowing into a bigger box and into a smaller one; anything left out uses defaultMorphSprings
onStart?: (to, from) => void Called when a flight sets off, including when it turns around or heads elsewhere mid-way
onRest?: (at) => void Called when it comes to rest as a state
onProgress?: (progress, to, from) => void Called every frame of a flight with how far along it is toward to, from 0 to 1. from is null when it started over mid-way. Turned around, to is the other end. Swapped at once, it’s called once with 1

Each direction has five channels: x and y move the center, width and height change the size, and progress drives the corners, the contents and shared elements. Pass only the channels you change; defaultMorphSprings is exported as a starting point. A spring is { duration, bounce, settle? }: duration in seconds, bounce from 0 (no overshoot) to 1, and settle the bounce after the first overshoot, bounce by default. Shrinking from rest, the shape holds a beat while the contents go first.

const morph = createMorph({
  states: [button, panel],
  spring: {
    grow: { y: { duration: 0.4, bounce: 0.3, settle: 0.1 } },
  },
});

onProgress moves something of the page with the flight, on the same spring: a scrim behind the panel, a chevron.

const morph = createMorph({
  states: [button, panel],
  onProgress(progress, to) {
    const open = to === panel ? progress : 1 - progress;
    scrim.style.opacity = String(open);
  },
});

API

Member Meaning
current The state asked for; changes immediately
moving Whether a flight is under way
to(state, move?) A Promise<boolean>: true once it rests there, false if it was sent elsewhere or destroyed first. { instant: true } swaps at once, as with reduced motion
update() Measures the states again and follows them mid-flight
add(state) Takes one more element as a state, hidden until it’s asked for: an item added to a list later
remove(state) Lets go of a state and gives its inline styles back, before it leaves the page. Not the current one; one end of a flight under way lands it at once
destroy() Stops the flight, removes the shape and gives every state its own inline styles back. Later calls do nothing

Sent back mid-way, it turns around keeping its speed. Sent to a third state mid-way, it starts over from where the shape is. During a flight it follows scrolling, window resizes and a state’s own size changes (contents loaded on the way) by itself; call update() after anything else moves a state. destroy() doesn’t remove your own event listeners.

Layout

The bigger state

Flies itself, either way: the real element, moved through its inline transform, opacity, filter and clip-path alone, so its text never reflows and it stays live (pressed, typed into, playing). Don’t transition those. Its own translation, such as translate: -50% -50% for centering, is kept. Its surface steps aside for the shape meanwhile, so it has to be above it (z-index: 2).

The smaller state

Rides in the shape as a copy, taken as it is when the shape sets off or turns around, staying where it is and fading as the shape leaves it. The copy keeps its classes and attributes, so style states with them: selectors through the state’s parent don’t reach it.

Surfaces

The shape is the bigger state’s surface the whole way. Where the smaller one’s is another as drawn (fill, border, shadow, backdrop, pseudo-elements), it lies over it and fades as the shape grows: a solid control dissolves into glass, a tint into none.

The corners

Each corner animates from one state’s radius to the other’s and stays circular, so a sheet rounded only on top stays square below. Percentages resolve against the box; an elliptical corner uses the smaller of its two radii. Where corner-shape is supported, the shape animates too.

Hidden states

A state not shown is hidden (opacity: 0, visibility: hidden, with data-fk-away) and takes no presses. One that holds focus as it goes keeps it: it’s only made transparent, so a control’s aria-expanded is still announced. Pressing the shape on its way presses the smallest state under it, so a button can be pressed again before the shape is home.

Mounting

Can be imported on the server. Create the morph in the browser after mounting or hydration, and call destroy() on unmount. The shape is added next to the smaller state only while it flies, so :last-child and similar selectors still match at rest. With reduced motion, the states swap instantly.

With Liquid Blur

Inside a Liquid Blur glass group, the shape is placed by the state in the group, and the behavior depends on where the other one is:

  • Beside the neighbors: the shape stays in the group and merges with the neighboring glass as it moves.
  • Over a neighbor: the shape leaves the group and flies as separate glass above it (z-index: 1). A stub stays in the state’s place and shrinks away, so the connection to the neighbors fades out gradually.

In a group the shape wears the bigger state’s classes, as the group needs to melt it, so surfaces don’t dissolve into each other there. The two packages don’t import each other; they coordinate through attributes in the DOM.

If a parent clips with overflow: hidden, the shape would be cut off. Pass a container and it’s placed there instead. It then inherits theme and custom properties from the container.

React

A small hook creates the morph on mount and destroys it on unmount. Keep the panel’s visibility: hidden in CSS, not inline, so a re-render doesn’t override the morph.

import { useEffect, useRef, useState } from "react";
import { createMorph, type Morph, type MorphOptions } from "@fluidkit/morph";

/** A button and its panel; options are read once, on mount */
export function useMorph(options: Omit<MorphOptions, "states"> = {}) {
  const button = useRef<HTMLButtonElement>(null);
  const panel = useRef<HTMLDivElement>(null);
  const morph = useRef<Morph | null>(null);
  const [open, setOpen] = useState(false);
  useEffect(() => {
    const m = createMorph({
      ...options,
      states: [button.current!, panel.current!],
      onStart(to, from) {
        setOpen(to === panel.current);
        options.onStart?.(to, from);
      },
    });
    morph.current = m;
    return () => m.destroy();
  }, []);
  return {
    button,
    panel,
    open,
    toggle: () => morph.current?.to(open ? button.current! : panel.current!),
    close: () => morph.current?.to(button.current!),
  };
}

export function More() {
  const morph = useMorph();
  return (
    <div className="morph-scene">
      <button
        ref={morph.button}
        className="lb"
        type="button"
        aria-controls="panel"
        aria-expanded={morph.open}
        onClick={morph.toggle}
      >
        More
      </button>
      <div ref={morph.panel} id="panel" className="lb" role="menu">
        <button type="button" onClick={morph.close}>Close</button>
      </div>
    </div>
  );
}

Browser support

Any browser with ES2022 and clip-path: inset(). Corner shapes animate where corner-shape is supported; elsewhere corners stay round.

Limitations

  • Rotated or scaled ancestors aren’t supported, and the bigger state can’t have a transform of its own other than a translation.
  • The bigger state comes in magnified, up to 1.45 times its size, and takes that room in the page however it’s clipped. So that it doesn’t widen the page on its way, the nearest element it’s laid out in that spans the page (such as main) clips sideways (overflow-x: clip) while it flies. Without one below <body>, as with states right in the body or a body with side padding, put overflow-x: clip on an element around them yourself: on <body> it isn’t enough, iOS Safari pans to what overflows the body.
  • The smaller state’s copy is a snapshot: a video or a field in it doesn’t play or update on the way.
  • The bigger state’s pseudo-elements stay drawn while its surface steps aside.
  • Corners animate as circular arcs; the corner shape follows the top-left corner.
  • While the shape merges with a glass group, its corners are round.