Morph Panel <mv-morph-panel>

Um contêiner que anima a largura e a altura medidas ao trocar de visualização, com uma transição cruzada desfocada do conteúdo: drawers de várias etapas, popovers e uma variante “Dynamic Island” que cresce de uma pílula até um painel.

CategoriaTransições
TipoWeb Component (<mv-morph-panel>)
Statusestável
Keywordsmorph, 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

Instalação

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

Agente de IA com o servidor MCP do Marvelous UI: install_components({ slugs: ["morph-panel"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).

Arquivos copiados (dependências incluídas): 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.

Uso

Marcação de referência, para usar como ponto de partida e personalizar com atributos, data-* e variáveis CSS:

<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

NameTipoDescription
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

NameTipoDescription
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.

Esta página foi traduzida com IA. Informar um problema de tradução