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.
Category Transitions Type Web Component (<mv-morph-panel>) Status stable Keywords morph, dynamic-island, family-drawer, multi-step, resize, crossfade, popover
When to use
A drawer or popover has several steps whose content differs in size and should resize smoothly between them A compact pill should grow into a status or controls panel, Dynamic Island style A menu should drill down into sub-views with a back button without jumping in size
Avoid when
Views are peers users switch between with visible labels → use Tabs instead A single floating panel with fixed content is enough → use Popover instead The flow is a long wizard where progress through numbered steps must be visible → use Stepper instead
Get Morph Panel. Included in every plan: the Pro pack ships all 226 components with the CLI and the MCP server used below.
See plans
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
Name Type Description viewstring Displayed view (reflected; changing it triggers the morph). collapsibleboolean Escape and outside clicks return to the first view. data-variantisland Black Dynamic Island–style pill with a spring on size.
Properties
Name Type Description viewsHTMLElement[] Views ([data-view] direct children). defaultViewstring Name of the first view.
Methods
Name Description show(name)Switches to a view; the promise resolves when the morph ends (interruptible).
Events
Name Description mv-changedetail.view, detail.previous.
Content structure
Name Description [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
Name Description mv-morph-header / -title / -backView header with a back button. mv-morph-optionLarge menu option; data-tone="danger".
CSS variables
Name Default Description --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.