Morph

Turn a button into a panel and back with one continuous shape. Springs can be interrupted and reversed at any point. Works with any element, and with Liquid Blur glass.

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

On this page

Get started

A morph switches between states: ordinary elements laid out where they should appear. One state is visible at a time. When you switch, a single shape animates from the visible state to the next one, and then the next state takes its place.

  1. Install.

    pnpm add @fluidkit/core
  2. Lay out both states where they should appear. Hide the panel with visibility: hidden (not display: none, so it can be measured) and put it above the button with z-index: 2. Style the states however you like; here both are Liquid Blur glass.

    <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 once the elements are in the DOM, and call to() from your handlers. Accessibility details like aria-expanded, Escape and focus are up to you; onStart and onRest are good places for them.

    import "@fluidkit/core/style.css"; // the glass, if the states are glass
    import { createMorph } from "@fluidkit/core";
    
    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 already interactive while it animates in
        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();
  1. Install. The React components and hooks, with all of core (React 18+).

    pnpm add @fluidkit/react
  2. Add the states. Each Morph.State has a value. A Morph.Trigger switches to its value when clicked. A state can also be a trigger: here the button opens the panel.

    import "@fluidkit/react/style.css"; // the glass, if the states are glass
    import { Morph } from "@fluidkit/react";
    
    export function More() {
      return (
        <div className="morph-scene">
          <Morph.Root>
            <Morph.State value="button" id="more" className="lb" render={<Morph.Trigger value="panel" />}>
              More
            </Morph.State>
            <Morph.State value="panel" id="panel" className="lb">
              <p>Panel content</p>
              <Morph.Trigger value="button">Close</Morph.Trigger>
            </Morph.State>
          </Morph.Root>
        </div>
      );
    }
  3. Lay out both states where they should appear. Hide the panel with visibility: hidden in CSS, not in the style prop: the morph manages inline opacity and visibility. Put the panel above the button with z-index: 2.

    .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;
    }

    Components are marked "use client". On the server they render plain markup, and the panel stays hidden by your CSS.

  1. Install. The Vue components and composables, with all of core (Vue 3.5+).

    pnpm add @fluidkit/vue
  2. Add the states. Each Morph.State has a value. A Morph.Trigger switches to its value when clicked. A state can also be a trigger, with as-child: here the button opens the panel.

    <script setup lang="ts">
    import "@fluidkit/vue/style.css"; // the glass, if the states are glass
    import { Morph } from "@fluidkit/vue";
    </script>
    
    <template>
      <div class="morph-scene">
        <Morph.Root>
          <Morph.State value="button" as-child>
            <Morph.Trigger id="more" class="lb" value="panel">More</Morph.Trigger>
          </Morph.State>
          <Morph.State value="panel" id="panel" class="lb">
            <p>Panel content</p>
            <Morph.Trigger value="button">Close</Morph.Trigger>
          </Morph.State>
        </Morph.Root>
      </div>
    </template>
  3. Lay out both states where they should appear. Hide the panel with visibility: hidden in CSS, not in style: the morph manages inline opacity and visibility. Put the panel above the button with z-index: 2.

    .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;
    }

    On the server, including Nuxt, the components render plain markup, and the panel stays hidden by your CSS.

  1. Install. The Svelte components and runes, with all of core (Svelte 5.29+).

    pnpm add @fluidkit/svelte
  2. Add the states. Each Morph.State has a value. A Morph.Trigger switches to its value when clicked. A state can also be a trigger, with a child snippet: here the button opens the panel.

    <script lang="ts">
      import "@fluidkit/svelte/style.css"; // the glass, if the states are glass
      import { Morph } from "@fluidkit/svelte";
    </script>
    
    <div class="morph-scene">
      <Morph.Root>
        <Morph.State value="button">
          {#snippet child({ props })}
            <Morph.Trigger {...props} id="more" class="lb" value="panel">More</Morph.Trigger>
          {/snippet}
        </Morph.State>
        <Morph.State value="panel" id="panel" class="lb">
          <p>Panel content</p>
          <Morph.Trigger value="button">Close</Morph.Trigger>
        </Morph.State>
      </Morph.Root>
    </div>
  3. Lay out both states where they should appear. Hide the panel with visibility: hidden in CSS, not in style: the morph manages inline opacity and visibility. Put the panel above the button with z-index: 2.

    .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;
    }

    On the server, including SvelteKit, the components render plain markup, and the panel stays hidden by your CSS. Styles for the states go in a global stylesheet or :global(): a component’s scoped styles don’t reach the parts’ elements.

Examples

The shape takes on the look of the states, so the result depends on how you style them.

Plain CSS

No Liquid Blur: a solid pill turns into a solid panel.

From solid to glass

The pill is glass with a solid layer on top; the panel is plain glass. Because the two look different, the solid layer fades into glass on the way. No extra code needed.

In a glass group

A button in a toolbar whose glass is merged. The panel opens on top of the other buttons, so the shape leaves the group and animates as separate glass. See With Liquid Blur.

Shared elements

Give an element in each of two states the same data-fk-match, and it moves from one position to the other during the morph. Here the icon moves into the menu title, and the label moves into the selected option. Pick another option and the pill comes back with the new label.

The shared element keeps its proportions and scales with the shape. In the larger state it sticks to the nearest edge, so a label on the left stays on the left as the shape grows. The morph reads data-fk-match each time it starts, so you can move the attribute when the shared element changes, as the example does with the selected option.

More than two states

A morph can have any number of states and can go from any state to any other. Here a button grows into a bar, and the bar into a card; the tabs jump directly between any two. The play button is shared by all three states, the artwork and title by the bar and the card. A shared element only moves between two states that both have it.

If you switch back mid-way, the morph reverses smoothly. If you switch to a third state mid-way, it continues from where the shape currently is.

OptionsTypeScript

createMorph(options: MorphOptions): Morph

Option Description
states: HTMLElement[] The state elements. Each must be in the DOM, laid out where it should appear
initial?: HTMLElement The state shown first. Default: the first one
container?: HTMLElement Where to put the shape while it animates. Use it when a state’s parent has overflow: hidden and would clip the shape. Default: next to the smaller state
spring?: { grow?, shrink? } Springs for growing into a larger state and shrinking into a smaller one. Default: defaultMorphSprings
onStart?: (to, from) => void Called when an animation starts, including when it reverses or changes target mid-way
onRest?: (at) => void Called when the animation finishes
onProgress?: (progress, to, from) => void Called every frame with progress toward to, from 0 to 1. from is null if the animation was redirected mid-way. Called once with 1 when the switch is instant

Each direction has five spring channels: x and y for the position, width and height for the size, and progress for corners, content and shared elements. Pass only the ones you want to change. A spring is { duration, bounce, settle? }: duration in seconds, bounce from 0 (no overshoot) to 1, and settle, the bounce after the first overshoot (default: same as bounce).

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

Use onProgress to animate something else in sync, like a backdrop or a chevron:

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

APITypeScript

Member Description
current The target state. Changes as soon as you call to()
moving true while animating
to(state, move?) Animates to state. Returns a Promise<boolean>: true when it arrives, false if it was redirected or destroyed first. Pass { instant: true } to switch without animation
update() Re-measures the states. Call it if something moves a state while it animates
add(state) Adds a state, hidden until you switch to it. Useful for list items added later
remove(state) Removes a state and restores its inline styles. Call it before removing the element from the page. You can’t remove the current state
set(settings) Updates spring, onStart, onRest or onProgress without recreating the morph. New springs apply from the next animation
destroy() Stops the animation, removes the shape and restores every state’s inline styles

The morph follows scrolling, window resizes and size changes of the states on its own. destroy() doesn’t remove event listeners you added.

PartsReactVueSvelte

Part Renders Description
Morph.Root nothing Holds the props below and connects the parts inside it
Morph.State div A state with a value. Has data-current while it’s the target
Morph.Trigger button Switches to its value on click. instant skips the animation. Call event.preventDefault() in your own onClick to cancel
Morph.Match span A shared element: moves to the Morph.Match with the same name in the other state. No name, no match

Morph.Root props:

Prop Description
value?: string The current state, for controlled use
defaultValue?: string The initial state, for uncontrolled use. Default: the first state rendered
onValueChange?: (value) => void Called when a trigger or to() asks for another state
onStart?: (to, from) => void Called when an animation starts, including when it reverses or changes target mid-way
onRest?: (at) => void Called when the animation finishes
onProgress?: (progress, to, from) => void Called every frame with progress toward to, from 0 to 1. from is null if the animation was redirected mid-way
spring?: { grow?, shrink? } Springs for growing and shrinking, per channel (x, y, width, height, progress). Default: defaultMorphSprings. See core
container?: HTMLElement | RefObject Where to put the shape while it animates, if a parent with overflow: hidden would clip it

Controlled. Pass value and onValueChange to keep the state in your component. Triggers then only call onValueChange. The animation starts after the new value renders, so anything the same click changed (a label, a match) is already in place.

const [value, setValue] = useState("button");
<Morph.Root value={value} onValueChange={setValue}>…</Morph.Root>

Adding and removing states. States rendered later, like new list items, are picked up automatically. Removed states are released and get their styles back. Changing props never recreates the morph.

Part Renders Description
Morph.Root nothing Holds the props below and connects the parts inside it
Morph.State div A state with a value. Has data-current while it’s the target
Morph.Trigger button Switches to its value on click. instant skips the animation. Call event.preventDefault() in your own @click to cancel
Morph.Match span A shared element: moves to the Morph.Match with the same name in the other state. No name, no match

Every part also takes as-child, see Composition.

Morph.Root props and events:

Prop or event Description
v-model The current state. Without it, the root keeps its own, starting at defaultValue
defaultValue?: string The initial state, for uncontrolled use. Default: the first state rendered
@start="(to, from) => …" An animation started, including when it reversed or changed target mid-way
@rest="(at) => …" The animation finished
@progress="(progress, to, from) => …" Every frame, with progress toward to from 0 to 1. from is null if the animation was redirected mid-way
spring?: { grow?, shrink? } Springs for growing and shrinking, per channel (x, y, width, height, progress). Default: defaultMorphSprings. See core
container?: HTMLElement | Ref Where to put the shape while it animates, if a parent with overflow: hidden would clip it

Controlled. With v-model, triggers update your ref, and the animation starts after the new value renders, so anything the same click changed (a label, a match) is already in place. To accept or refuse a change yourself, pass :model-value and handle @update:model-value.

<Morph.Root v-model="state">…</Morph.Root>

Adding and removing states. States rendered later, like new list items, are picked up automatically. Removed states are released and get their styles back. Changing props never recreates the morph.

Part Renders Description
Morph.Root nothing Holds the props below and connects the parts inside it
Morph.State div A state with a value. Has data-current while it’s the target
Morph.Trigger button Switches to its value on click. instant skips the animation. Call event.preventDefault() in your own onclick to cancel
Morph.Match span A shared element: moves to the Morph.Match with the same name in the other state. No name, no match

Every part also takes a child snippet, see Composition.

Morph.Root props:

Prop Description
bind:value The current state. Without it, the root keeps its own, starting at defaultValue
defaultValue?: string The initial state, for uncontrolled use. Default: the first state rendered
onValueChange={(value) => …} A state was asked for, by a trigger
onStart={(to, from) => …} An animation started, including when it reversed or changed target mid-way
onRest={(at) => …} The animation finished
onProgress={(progress, to, from) => …} Every frame, with progress toward to from 0 to 1. from is null if the animation was redirected mid-way
spring?: { grow?, shrink? } Springs for growing and shrinking, per channel (x, y, width, height, progress). Default: defaultMorphSprings. See core
container?: HTMLElement | null Where to put the shape while it animates, if a parent with overflow: hidden would clip it. An element from bind:this

Controlled. With bind:value, triggers update your state, and the animation starts after the new value renders, so anything the same click changed (a label, a match) is already in place. To accept or refuse a change yourself, bind a getter and a setter: bind:value={() => shown, (next) => allowed(next) && (shown = next)}.

<Morph.Root bind:value={shown}>…</Morph.Root>

Adding and removing states. States rendered later, like new list items, are picked up automatically. Removed states are released and get their styles back. Changing props never recreates the morph.

CompositionReactVueSvelte

Render as your own element. Every part accepts render, as in Base UI: pass an element and our props are merged into it, or pass a function that receives the props and state. To make a state a trigger, render it as one:

<Morph.State value="button" render={<Morph.Trigger value="panel" />}>More</Morph.State>
<Morph.State value="panel" render={(props, { current }) => <dialog {...props} open={current} />} />

Glass. Make a state glass with className="lb", or render it as Glass. Inside a Glass.Group, the shape merges with neighboring glass as it moves.

import { Glass } from "@fluidkit/react";

<Glass.Group className="toolbar">
  <Glass interactive render={<button aria-label="Back" />}><BackIcon /></Glass>
  <Morph.State value="button" render={<Glass interactive render={<Morph.Trigger value="panel" />} />}>
    <ShareIcon />
  </Morph.State>
</Glass.Group>

Shared elements. Morph.Match can also render as your own element (render={<svg … />}). Give the name to whichever element is shared right now, as the sort menu above does with the selected option.

Accessibility. aria-expanded, Escape and focus are up to you. Use value to know the current state, and onStart / onRest to know when an animation starts and ends.

Render as your own element. Every part accepts as-child, as in Reka UI: instead of its own element, the part renders the first element in its slot, with our attributes, classes, styles, listeners and refs merged in. To make a state a trigger, put the trigger in it with as-child:

<Morph.State value="button" as-child>
  <Morph.Trigger value="panel">More</Morph.Trigger>
</Morph.State>

Glass. Make a state glass with class="lb", or render it as Glass. Inside a Glass.Group, the shape merges with neighboring glass as it moves.

<Glass.Group class="toolbar">
  <Glass interactive as-child><button aria-label="Back"><BackIcon /></button></Glass>
  <Morph.State value="button" as-child>
    <Glass interactive as-child><Morph.Trigger value="panel"><ShareIcon /></Morph.Trigger></Glass>
  </Morph.State>
</Glass.Group>

Shared elements. Morph.Match can also render as your own element with as-child. Give the name to whichever element is shared right now, as the sort menu above does with the selected option.

Accessibility. aria-expanded, Escape and focus are up to you. Use the v-model value to know the current state, and @start / @rest to know when an animation starts and ends.

Render as your own element. Every part accepts a child snippet, as in bits-ui: instead of its own element, the part renders yours. Spread the props it gives onto your element: they carry our attributes, handlers and the attachment that connects it. To make a state a trigger, spread the state’s props onto the trigger:

<Morph.State value="button">
  {#snippet child({ props })}<Morph.Trigger {...props} value="panel">More</Morph.Trigger>{/snippet}
</Morph.State>

Glass. Make a state glass with class="lb", or render it as Glass. Inside a Glass.Group, the shape merges with neighboring glass as it moves.

<Glass.Group class="toolbar">
  <Glass interactive>
    {#snippet child({ props })}<button {...props} aria-label="Back"><BackIcon /></button>{/snippet}
  </Glass>
  <Morph.State value="button">
    {#snippet child({ props: state })}
      <Glass {...state} interactive>
        {#snippet child({ props })}<Morph.Trigger {...props} value="panel"><ShareIcon /></Morph.Trigger>{/snippet}
      </Glass>
    {/snippet}
  </Morph.State>
</Glass.Group>

Shared elements. Morph.Match can also render as your own element with a child snippet. Give the name to whichever element is shared right now, as the sort menu above does with the selected option.

Accessibility. aria-expanded, Escape and focus are up to you. Use the bound value to know the current state, and onStart / onRest to know when an animation starts and ends.

The hookReactVueSvelte

Use useMorph(options) when the parts don’t fit your markup. It takes the same options as Morph.Root and returns a ref for each state.

import { useMorph } from "@fluidkit/react";

const morph = useMorph({ defaultValue: "button" });

<button ref={morph.state("button")} className="lb" onClick={() => morph.to("panel")}>More</button>
<div ref={morph.state("panel")} className="lb panel">
  <button onClick={() => morph.to("button")}>Close</button>
</div>
Member Description
state(value) Ref for the state with this value. Stable between renders
value The current state
moving true while animating
to(value, move?) Switches to a state. Returns a Promise<boolean>: true when it arrives, false if it was redirected first. { instant: true } skips the animation
morph The underlying engine, once a state is mounted

Use useMorph(options) when the parts don’t fit your markup. Options can be an object, a ref or a getter; a getter is read again when what it uses changes. It returns a function ref for each state.

<script setup lang="ts">
import { useMorph } from "@fluidkit/vue";

const morph = useMorph({ defaultValue: "button" });
</script>

<template>
  <button :ref="morph.state('button')" class="lb" @click="morph.to('panel')">More</button>
  <div :ref="morph.state('panel')" class="lb panel">
    <button @click="morph.to('button')">Close</button>
  </div>
</template>
Member Description
state(value) Function ref for the state with this value, the same one each time
value Computed: the current state
moving Ref: true while animating
to(value, move?) Switches to a state. Returns a Promise<boolean>: true when it arrives, false if it was redirected first. { instant: true } skips the animation
morph Shallow ref to the underlying engine, once a state is mounted

Use useMorph(() => options) when the parts don’t fit your markup. Call it in a component’s script; the options getter is read again when what it uses changes. It returns an attachment for each state.

<script lang="ts">
  import { useMorph } from "@fluidkit/svelte";

  const morph = useMorph(() => ({ defaultValue: "button" }));
</script>

<button {@attach morph.state("button")} class="lb" onclick={() => morph.to("panel")}>More</button>
<div {@attach morph.state("panel")} class="lb panel">
  <button onclick={() => morph.to("button")}>Close</button>
</div>
Member Description
state(value) Attachment for the state with this value, the same one each time
value The current state
moving true while animating
to(value, move?) Switches to a state. Returns a Promise<boolean>: true when it arrives, false if it was redirected first. { instant: true } skips the animation
morph The underlying engine, once a state is mounted

How it works

The larger state

The real element animates, using only its inline transform, opacity, filter and clip-path. Its text doesn’t reflow, and it stays interactive while it moves. Don’t put transitions on those properties. A translation of your own, like translate: -50% -50%, is kept. It must sit above the shape (z-index: 2).

The smaller state

A snapshot of it rides inside the shape and fades out. The snapshot keeps the element’s classes and attributes, but not its parents, so don’t style states through parent selectors.

Surfaces

The shape uses the larger state’s background, border and shadow. If the smaller state looks different, its look fades out as the shape grows: a solid button dissolves into glass.

Corners

Each corner animates from one state’s radius to the other’s. A panel rounded only at the top stays square at the bottom. Where corner-shape is supported, it animates too.

Hidden states

Hidden states get opacity: 0, visibility: hidden and data-fk-away, and ignore clicks. A state that has focus when it hides stays focusable, so screen readers still announce it. Clicking the moving shape clicks the state underneath, so a button can be pressed again before the animation ends.

Mounting

Safe to import on the server. Create the morph in the browser after mount, and call destroy() on unmount. The shape exists only while animating, so selectors like :last-child work as usual. With reduced motion, states switch instantly.

Dialogs and popovers

A state can be a <dialog> or a popover. Open it before switching to it, so it can be measured, and close it after switching back:

const morph = createMorph({ states: [button, dialog] });

button.addEventListener("click", () => {
  dialog.showModal(); // or dialog.show(), panel.showPopover()
  morph.to(dialog);
});
close.addEventListener("click", async () => {
  // `false` if it was redirected on the way; then keep it open
  if (await morph.to(button)) dialog.close();
});

Dialogs and popovers render in the top layer, above everything else, including the shape. Fade the backdrop in with the animation so the shape isn’t dimmed early:

const morph = createMorph({
  states: [button, dialog],
  onProgress(progress, to) {
    dialog.style.setProperty("--open", String(to === dialog ? progress : 1 - progress));
  },
});
dialog::backdrop { opacity: var(--open, 1); }

In React, open the dialog in the trigger’s onClick, before switching, and close it when to() resolves:

import { useMergedRefs } from "@fluidkit/react";

const morph = useMorph({ defaultValue: "button" });
const dialog = useRef<HTMLDialogElement>(null);
const dialogRef = useMergedRefs(dialog, morph.state("dialog"));

<button ref={morph.state("button")} onClick={() => { dialog.current!.showModal(); morph.to("dialog"); }}>Open</button>
<dialog ref={dialogRef}>
  <button onClick={async () => (await morph.to("button")) && dialog.current!.close()}>Close</button>
</dialog>

In Vue, open the dialog in the click handler, before switching, and close it when to() resolves:

<script setup lang="ts">
import { shallowRef } from "vue";
import { useMorph } from "@fluidkit/vue";

const morph = useMorph({ defaultValue: "button" });
const dialog = shallowRef<HTMLDialogElement | null>(null);
// One ref for both: the dialog element, and the morph's state
const toDialog = morph.state("dialog");
const dialogRef = (el: unknown) => {
  dialog.value = el as HTMLDialogElement | null;
  toDialog(el);
};
const open = () => {
  dialog.value!.showModal();
  morph.to("dialog");
};
const close = async () => (await morph.to("button")) && dialog.value!.close();
</script>

<template>
  <button :ref="morph.state('button')" @click="open">Open</button>
  <dialog :ref="dialogRef">
    <button @click="close">Close</button>
  </dialog>
</template>

In Svelte, open the dialog in the click handler, before switching, and close it when to() resolves. bind:this and the state’s attachment sit side by side:

<script lang="ts">
  import { useMorph } from "@fluidkit/svelte";

  const morph = useMorph(() => ({ defaultValue: "button" }));
  let dialog = $state<HTMLDialogElement>();
  const open = () => {
    dialog!.showModal();
    morph.to("dialog");
  };
  const close = async () => (await morph.to("button")) && dialog!.close();
</script>

<button {@attach morph.state("button")} onclick={open}>Open</button>
<dialog bind:this={dialog} {@attach morph.state("dialog")}>
  <button onclick={close}>Close</button>
</dialog>

A popover with popover="auto" closes on any click outside it, including on the moving shape. Use popover="manual" and close it yourself.

With Liquid Blur

When a state is inside a Liquid Blur glass group, the behavior depends on where the other state is:

  • Next to the other buttons: the shape stays in the group and merges with the neighboring glass as it moves.
  • On top of them: the shape leaves the group and animates as separate glass above it (z-index: 1). The gap it leaves in the group closes smoothly.

If a parent has overflow: hidden, the shape would be clipped. Pass a container to place it elsewhere; it then inherits theme and CSS variables from that container.

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. The larger state can’t have its own transform other than a translation.
  • The larger state enters scaled up to 1.45×. To keep it from widening the page, the morph sets overflow-x: clip on the nearest full-width ancestor (like main) while animating. If the states sit directly in <body>, wrap them in an element with overflow-x: clip yourself; on <body> it doesn’t help in iOS Safari.
  • The smaller state is a snapshot: video and inputs inside it don’t update during the animation.
  • The larger state’s ::before and ::after stay visible while it animates.
  • Corners animate as circular arcs. Only the top-left corner-shape is used.
  • While merging with a glass group, corners are round.
PlaygroundIts hard cases, live: slowed down, on other backdrops, with their boxes drawn.