Morph Panel — <mv-morph-panel>

A container that animates its measured width and height when switching views, with a blurred crossfade of the content: multi-step drawers, popovers, and a “Dynamic Island” variant that grows from a pill into a panel.

CategoryTransitions
TypeWeb Component (<mv-morph-panel>)
Statusstable
Keywordsmorph, dynamic-island, family-drawer, multi-step, resize, crossfade, popover

When to use

Avoid when

Install

node scripts/add.mjs morph-panel --out ./src/marvelous

AI agent with the Marvelous UI MCP server: install_components({ slugs: ["morph-panel"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).

Files copied (dependencies included): tokens/tokens.css, core/base.css, core/dismiss.js, core/dom.js, core/element.js, core/focus.js, core/motion.js, components/morph-panel/morph-panel.js, components/morph-panel/morph-panel.css.

Usage

Canonical markup — start from it and customize with attributes, data-* and CSS variables:

<div id="morph-demo">
  <style>
    #morph-demo { display: flex; flex-wrap: wrap; gap: 2rem; align-items: flex-start; justify-content: center; width: 100%; }
    #morph-demo .md-col { display: grid; gap: 1rem; justify-items: center; }
    #morph-demo .md-phone { position: relative; width: 25rem; max-width: 100%; height: 17rem; padding-top: .75rem; border-radius: 2.25rem; overflow: hidden;
      background: radial-gradient(80% 90% at 20% 10%, oklch(0.78 0.13 250), transparent 60%), radial-gradient(70% 80% at 90% 90%, oklch(0.72 0.16 330), transparent 60%), oklch(0.6 0.12 280);
      box-shadow: inset 0 0 0 6px oklch(0.15 0 0), var(--mv-shadow-lg); }
    #morph-demo .md-phone::after { content: "09:41"; position: absolute; left: 1.6rem; top: 1.15rem; font: 600 .8rem var(--mv-font-sans); color: #fff; }
    #morph-demo .md-seg { display: flex; flex-wrap: wrap; justify-content: center; gap: .375rem; }
    #morph-demo .md-seg .mv-button[aria-pressed="true"] { background: var(--mv-fg); color: var(--mv-bg); }
    /* Island views */
    #morph-island { position: relative; z-index: 1; max-width: calc(100% - 1rem); }
    #morph-island .iv { display: flex; align-items: center; gap: .75rem; }
    #morph-island .iv-idle { width: 7.5rem; height: 1.5rem; justify-content: flex-end; padding: 0 .5rem; border: 0; background: none; color: inherit; cursor: pointer; border-radius: 1rem; }
    #morph-island .iv-idle i { width: .55rem; height: .55rem; border-radius: 50%; background: radial-gradient(circle at 35% 35%, oklch(0.45 0.05 250), oklch(0.2 0.02 250)); }
    #morph-island .iv-idle:focus-visible { outline: 2px solid oklch(0.7 0.15 250); }
    #morph-island .iv-call { width: 20rem; padding: .25rem .25rem .25rem .25rem; }
    #morph-island .iv-av { display: grid; place-items: center; width: 2.5rem; height: 2.5rem; flex: none; border-radius: 50%; font: 700 .8rem var(--mv-font-sans); background: linear-gradient(135deg, oklch(0.8 0.12 60), oklch(0.6 0.18 20)); color: #fff; }
    #morph-island .iv-txt { display: grid; line-height: 1.25; flex: 1; min-width: 0; }
    #morph-island .iv-txt small { color: oklch(0.7 0 0); font-size: .75rem; }
    #morph-island .iv-round { display: grid; place-items: center; width: 2.5rem; height: 2.5rem; border-radius: 50%; border: 0; color: #fff; cursor: pointer; flex: none; transition: scale .15s; }
    #morph-island .iv-round:active { scale: .92; }
    #morph-island .iv-round svg { width: 1.1rem; height: 1.1rem; }
    #morph-island .iv-music { display: grid; gap: .9rem; width: 21rem; padding: .9rem 1rem 1rem; }
    #morph-island .iv-row { display: flex; align-items: center; gap: .85rem; }
    #morph-island .iv-art { width: 3.5rem; height: 3.5rem; flex: none; border-radius: .9rem; background: linear-gradient(145deg, oklch(0.75 0.15 280), oklch(0.5 0.2 330)); box-shadow: 0 6px 20px -6px oklch(0.6 0.2 300 / .6); }
    #morph-island .iv-eq { display: flex; align-items: flex-end; gap: 3px; height: 1.25rem; margin-left: auto; }
    #morph-island .iv-eq span { width: 3px; border-radius: 2px; background: oklch(0.75 0.15 300); }
    #morph-island .iv-bar { display: grid; grid-template-columns: auto 1fr auto; align-items: center; gap: .6rem; font: 500 .7rem var(--mv-font-sans); color: oklch(0.7 0 0); font-variant-numeric: tabular-nums; }
    #morph-island .iv-bar b { height: 4px; border-radius: 4px; background: linear-gradient(90deg, #fff 32%, oklch(1 0 0 / .2) 32%); }
    #morph-island .iv-ctrl { display: flex; justify-content: center; gap: 1.75rem; }
    #morph-island .iv-ctrl button { display: grid; place-items: center; width: 2.25rem; height: 2.25rem; border: 0; border-radius: 50%; background: none; color: #fff; cursor: pointer; }
    #morph-island .iv-ctrl button:hover { background: oklch(1 0 0 / .1); }
    #morph-island .iv-ctrl svg { width: 1.35rem; height: 1.35rem; }
    #morph-island .iv-timer { width: 13rem; height: 1.5rem; justify-content: space-between; padding: 0 .5rem; color: oklch(0.8 0.16 65); font: 600 .95rem var(--mv-font-sans); font-variant-numeric: tabular-nums; }
    #morph-island .iv-timer svg { width: 1.1rem; height: 1.1rem; }
    /* Drawer views */
    #morph-drawer { width: min(22rem, 100%); }
    #morph-drawer p { margin: 0; color: var(--mv-fg-muted); font-size: .875rem; line-height: 1.5; }
    #morph-drawer .dv { display: grid; gap: .5rem; }
    #morph-drawer .dv-head { margin-bottom: .75rem; }
    #morph-drawer .dv-list { display: grid; gap: .75rem; margin: .25rem 0 1rem; padding: 0; list-style: none; font-size: .875rem; }
    #morph-drawer .dv-list li { display: flex; gap: .65rem; align-items: flex-start; color: var(--mv-fg-muted); }
    #morph-drawer .dv-list svg { width: 1.1rem; height: 1.1rem; flex: none; color: var(--mv-fg); margin-top: .1rem; }
    #morph-drawer .dv-actions { display: grid; grid-template-columns: 1fr 1fr; gap: .5rem; margin-top: .5rem; }
    #morph-drawer .dv-words { display: grid; grid-template-columns: repeat(3, 1fr); gap: .4rem; margin: .25rem 0 .75rem; padding: 0; list-style: none; counter-reset: w; }
    #morph-drawer .dv-words li { counter-increment: w; padding: .4rem .5rem; border-radius: var(--mv-radius-md); background: var(--mv-bg-muted); font: 500 .8rem var(--mv-font-mono); filter: blur(4px); transition: filter .3s; }
    #morph-drawer .dv-words:hover li, #morph-drawer .dv-words:focus-within li { filter: none; }
    #morph-drawer .dv-words li::before { content: counter(w) ". "; color: var(--mv-fg-subtle); }
  </style>

  <div class="md-col">
    <div class="md-phone">
      <mv-morph-panel id="morph-island" data-variant="island" collapsible aria-label="Dynamic Island">
        <div data-view="repos">
          <button class="iv iv-idle" type="button" data-mv-view="musique" aria-label="Show player"><i></i></button>
        </div>
        <div data-view="appel" hidden>
          <div class="iv iv-call">
            <span class="iv-av" aria-hidden="true">MA</span>
            <span class="iv-txt"><small>Incoming call…</small><b>Mom</b></span>
            <button class="iv-round" type="button" style="background:oklch(0.62 0.21 25)" aria-label="Decline" data-mv-view="repos"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 9c-2.2 0-4.3.4-6.2 1.2a1.5 1.5 0 0 0-.9 1.4v2.2c0 .8.8 1.4 1.6 1.2l2.6-.7c.6-.2 1-.7 1-1.3v-1.3a10 10 0 0 1 3.8 0V13c0 .6.4 1.1 1 1.3l2.6.7c.8.2 1.6-.4 1.6-1.2v-2.2c0-.6-.4-1.2-.9-1.4A16 16 0 0 0 12 9z"/></svg></button>
            <button class="iv-round" type="button" style="background:oklch(0.68 0.18 150)" aria-label="Accept" data-mv-view="minuteur"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M6.6 10.8a15 15 0 0 0 6.6 6.6l2.2-2.2c.3-.3.7-.4 1-.2 1.1.4 2.3.6 3.6.6.6 0 1 .4 1 1V20c0 .6-.4 1-1 1A17 17 0 0 1 3 4c0-.6.4-1 1-1h3.5c.6 0 1 .4 1 1 0 1.3.2 2.5.6 3.6.1.3 0 .7-.2 1z"/></svg></button>
          </div>
        </div>
        <div data-view="musique" hidden>
          <div class="iv-music">
            <div class="iv-row">
              <span class="iv-art" aria-hidden="true"></span>
              <span class="iv-txt"><b>Starry Night</b><small>Pale Moon · Horizons</small></span>
              <span class="iv-eq" aria-hidden="true"><span style="height:40%"></span><span style="height:90%"></span><span style="height:60%"></span><span style="height:100%"></span><span style="height:45%"></span></span>
            </div>
            <div class="iv-bar"><span>1:12</span><b role="progressbar" aria-label="Progress" aria-valuenow="32" aria-valuemin="0" aria-valuemax="100"></b><span>-2:33</span></div>
            <div class="iv-ctrl">
              <button type="button" aria-label="Previous"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M6 5h2v14H6zM20 5v14L9 12z"/></svg></button>
              <button type="button" aria-label="Pause"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M7 5h4v14H7zM13 5h4v14h-4z"/></svg></button>
              <button type="button" aria-label="Next"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M16 5h2v14h-2zM4 5v14l11-7z"/></svg></button>
            </div>
          </div>
        </div>
        <div data-view="minuteur" hidden>
          <button class="iv iv-timer" type="button" data-mv-view="repos" aria-label="Timer 4:59, hide">
            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><circle cx="12" cy="13" r="8"/><path d="M12 9v4l2.5 2M9 2h6"/></svg>
            <span>04:59</span>
          </button>
        </div>
      </mv-morph-panel>
    </div>
    <div class="md-seg" role="group" aria-label="Island state">
      <button class="mv-button" data-variant="secondary" data-size="sm" type="button" data-mv-morph="#morph-island" data-mv-view="repos" aria-pressed="true">Idle</button>
      <button class="mv-button" data-variant="secondary" data-size="sm" type="button" data-mv-morph="#morph-island" data-mv-view="appel" aria-pressed="false">Call</button>
      <button class="mv-button" data-variant="secondary" data-size="sm" type="button" data-mv-morph="#morph-island" data-mv-view="musique" aria-pressed="false">Music</button>
      <button class="mv-button" data-variant="secondary" data-size="sm" type="button" data-mv-morph="#morph-island" data-mv-view="minuteur" aria-pressed="false">Timer</button>
    </div>
  </div>

  <mv-morph-panel id="morph-drawer">
    <section class="dv" data-view="menu">
      <header class="dv-head">
        <h3 class="mv-morph-title">Main wallet</h3>
        <p>0x71C…9A3F · 2.48 ETH</p>
      </header>
      <button class="mv-morph-option" type="button" data-mv-view="cle"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="7.5" cy="15.5" r="4.5"/><path d="m10.7 12.3 9.3-9.3M17 6l3 3M14 9l2 2"/></svg>View private key</button>
      <button class="mv-morph-option" type="button" data-mv-view="phrase"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6M8 13h8M8 17h5"/></svg>View recovery phrase</button>
      <button class="mv-morph-option" type="button" data-mv-view="supprimer" data-tone="danger"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7h16M9.5 7V4.5h5V7M6.5 7l1 13h9l1-13"/></svg>Remove wallet</button>
    </section>

    <section class="dv" data-view="cle" hidden>
      <header class="mv-morph-header">
        <button class="mv-morph-back" type="button" data-mv-view="menu" aria-label="Back"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="m15 18-6-6 6-6"/></svg></button>
        <h3 class="mv-morph-title">Private key</h3>
      </header>
      <ul class="dv-list">
        <li><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 2 4 6v6c0 5 3.4 9 8 10 4.6-1 8-5 8-10V6z"/></svg>Your key gives full access to your funds.</li>
        <li><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="9"/><path d="m5.6 5.6 12.8 12.8"/></svg>Never share it, not even with support.</li>
        <li><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>Keep it offline, somewhere safe.</li>
      </ul>
      <div class="dv-actions">
        <button class="mv-button" data-variant="secondary" type="button" data-mv-view="menu">Cancel</button>
        <button class="mv-button" type="button">Reveal</button>
      </div>
    </section>

    <section class="dv" data-view="phrase" hidden>
      <header class="mv-morph-header">
        <button class="mv-morph-back" type="button" data-mv-view="menu" aria-label="Back"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="m15 18-6-6 6-6"/></svg></button>
        <h3 class="mv-morph-title">Recovery phrase</h3>
      </header>
      <p>Hover to reveal the 12 words. Write them down in order.</p>
      <ol class="dv-words" tabindex="0" aria-label="Recovery phrase">
        <li>lantern</li><li>tide</li><li>velvet</li><li>compass</li><li>slate</li><li>cherry</li>
        <li>cliff</li><li>storm</li><li>ginger</li><li>comet</li><li>jasmine</li><li>granite</li>
      </ol>
      <button class="mv-button" data-variant="secondary" type="button">Copy to clipboard</button>
    </section>

    <section class="dv" data-view="supprimer" hidden>
      <header class="mv-morph-header">
        <button class="mv-morph-back" type="button" data-mv-view="menu" aria-label="Back"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="m15 18-6-6 6-6"/></svg></button>
        <h3 class="mv-morph-title">Remove wallet?</h3>
      </header>
      <p>Without your recovery phrase, this wallet will be permanently inaccessible.</p>
      <div class="dv-actions">
        <button class="mv-button" data-variant="secondary" type="button" data-mv-view="menu">Cancel</button>
        <button class="mv-button" data-variant="destructive" type="button" data-mv-view="menu">Remove</button>
      </div>
    </section>
  </mv-morph-panel>

  <script type="module">
    // Demo: keep the segmented control in sync with the island's state.
    const island = document.getElementById("morph-island");
    const seg = island.closest(".md-col").querySelector(".md-seg");
    island.addEventListener("mv-change", (e) => {
      for (const b of seg.querySelectorAll("button")) b.setAttribute("aria-pressed", String(b.dataset.mvView === e.detail.view));
    });
  </script>
</div>

API

Attributes

NameTypeDescription
viewstringDisplayed view (reflected; changing it triggers the morph).
collapsiblebooleanEscape and outside clicks return to the first view.
data-variantislandBlack Dynamic Island–style pill with a spring on size.

Properties

NameTypeDescription
viewsHTMLElement[]Views ([data-view] direct children).
defaultViewstringName of the first view.

Methods

NameDescription
show(name)Switches to a view; the promise resolves when the morph ends (interruptible).

Events

NameDescription
mv-changedetail.view, detail.previous.

Content structure

NameDescription
[data-view="name"]Views (direct children); the others are hidden.
[data-mv-view="name"]Internal trigger; data-mv-view="default" returns to the first view.
[data-mv-morph="#id"][data-mv-view]External trigger, anywhere on the page.

CSS classes

NameDescription
mv-morph-header / -title / -backView header with a back button.
mv-morph-optionLarge menu option; data-tone="danger".

CSS variables

NameDefaultDescription
--mv-morph-paddingvar(--mv-space-5) (island: space-2)Inner padding.
--mv-morph-radiusvar(--mv-radius-2xl) (island: 1.75rem)Radius; on the island it automatically shrinks into a pill when the height is small.
--mv-morph-bgvar(--mv-surface-raised)Panel background.
--mv-morph-island-bg / --mv-morph-island-fgblack / whiteIsland colors.

Accessibility

Inactive views are hidden (out of the accessibility tree), and the outgoing view is inert during the animation. If focus was in the old view, it moves to the first focusable element of the new one ([autofocus] takes priority). Sizes are measured, so no content gets clipped. Reduced motion: instant switch.