Exclusivebeta

Skeleton Morph — <mv-skeleton-morph>

The skeleton doesn’t make way for the content, it becomes it: each block is paired with a real element (data-skeleton-for, then kind, order and geometry) and travels to it. A text bar reaches its real line, takes its exact width, then retracts while the text types itself out behind it; a circle morphs into an avatar, a rectangle into an image or a button. Works with resolve(), a <template> or children swapped by a framework, and can play in reverse.

CategoryFeedback
TypeWeb Component (<mv-skeleton-morph>)
Statusbeta
Also installsskeleton
Keywordsexclusive, skeleton, loading, morph, flip, shared-element, placeholder, chargement, transition, async

When to use

Avoid when

Install

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

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

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

Usage

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

<div id="sm-demo" class="sm-demo" lang="en">
  <style>
    .sm-demo { width: min(100%, 62rem); margin-inline: auto; display: grid; gap: var(--mv-space-4); font-family: var(--mv-font-sans); }
    .sm-bar { display: flex; align-items: center; justify-content: space-between; gap: var(--mv-space-3); flex-wrap: wrap; }
    .sm-bar p { margin: 0; font-size: var(--mv-text-sm); color: var(--mv-fg-muted); }
    .sm-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(20rem, 1fr)); gap: var(--mv-space-4); align-items: start; }
    .sm-card { background: var(--mv-surface); border: 1px solid var(--mv-border); border-radius: var(--mv-radius-xl); padding: var(--mv-space-5); box-shadow: var(--mv-shadow-sm); }
    .sm-card > h3 { margin: 0 0 var(--mv-space-4); font-size: var(--mv-text-sm); font-weight: var(--mv-weight-semibold); color: var(--mv-fg-muted); letter-spacing: .02em; }
    .sm-post { display: grid; gap: var(--mv-space-4); }
    .sm-head { display: flex; align-items: center; gap: var(--mv-space-3); }
    .sm-avatar { width: 2.75rem; height: 2.75rem; border-radius: 50%; flex: none; }
    .sm-who { flex: 1; min-width: 0; display: grid; gap: .25rem; }
    .sm-name { font-size: var(--mv-text-base); font-weight: var(--mv-weight-semibold); line-height: 1.2; }
    .sm-meta { font-size: var(--mv-text-xs); color: var(--mv-fg-muted); line-height: 1.2; }
    .sm-body { margin: 0; font-size: var(--mv-text-sm); line-height: 1.6; color: var(--mv-fg); }
    .sm-media { height: 8.5rem; border-radius: var(--mv-radius-lg); overflow: hidden; position: relative;
      background: linear-gradient(135deg, color-mix(in oklch, var(--mv-accent) 22%, var(--mv-surface)), color-mix(in oklch, var(--mv-info) 18%, var(--mv-surface))); }
    .sm-media svg { position: absolute; inset: 0; width: 100%; height: 100%; }
    .sm-foot { display: flex; align-items: center; gap: var(--mv-space-2); }
    .sm-foot .sm-stats { flex: 1; font-size: var(--mv-text-xs); color: var(--mv-fg-muted); }
    .sm-rows { display: grid; }
    .sm-row { display: grid; grid-template-columns: 1fr auto 5.5rem 4.75rem; align-items: center; gap: var(--mv-space-3); padding-block: .7rem; border-top: 1px solid var(--mv-border); font-size: var(--mv-text-sm); }
    .sm-row:first-child { border-top: 0; padding-top: 0; }
    .sm-row .sm-ref { color: var(--mv-fg-muted); font-family: var(--mv-font-mono); font-size: var(--mv-text-xs); }
    .sm-row .sm-amt { text-align: right; font-variant-numeric: tabular-nums; font-weight: var(--mv-weight-medium); }
    .sm-row .mv-badge { justify-self: end; }
    .sm-row .mv-skeleton { height: .8rem; }
  </style>

  <div class="sm-bar">
    <p>Skeleton blocks become the content: each bar travels to its real line, takes its exact width, then the text types itself out behind it.</p>
    <button class="mv-button" data-variant="outline" type="button" data-reload disabled>Reload</button>
  </div>

  <div class="sm-grid">
    <article class="sm-card">
      <h3>News feed</h3>
      <mv-skeleton-morph data-feed>
        <div class="sm-post">
          <div class="sm-head">
            <span class="mv-skeleton" data-shape="circle" data-skeleton-for="avatar" style="--mv-skeleton-size:2.75rem"></span>
            <div class="sm-who">
              <span class="mv-skeleton" style="width:42%;height:.9rem"></span>
              <span class="mv-skeleton" style="width:68%;height:.7rem"></span>
            </div>
            <span class="mv-skeleton" data-shape="button" style="width:4.25rem;height:1.375rem;border-radius:var(--mv-radius-sm)"></span>
          </div>
          <div class="mv-skeleton-text" style="gap:.7rem">
            <span class="mv-skeleton"></span><span class="mv-skeleton"></span><span class="mv-skeleton" style="width:48%"></span>
          </div>
          <span class="mv-skeleton" data-shape="media" data-skeleton-for="media" style="aspect-ratio:auto;height:8.5rem"></span>
          <div class="sm-foot">
            <span class="mv-skeleton" style="width:9.5rem;height:.7rem;margin-right:auto"></span>
            <span class="mv-skeleton" data-shape="button" style="width:4.5rem;height:2rem"></span>
            <span class="mv-skeleton" data-shape="button" style="width:6.25rem;height:2rem"></span>
          </div>
        </div>
        <template data-content>
          <div class="sm-post">
            <div class="sm-head">
              <img class="sm-avatar" data-morph="avatar" alt="" src="data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 44 44'><defs><linearGradient id='g' x1='0' y1='0' x2='1' y2='1'><stop offset='0' stop-color='%23f59e0b'/><stop offset='1' stop-color='%23db2777'/></linearGradient></defs><rect width='44' height='44' fill='url(%23g)'/><text x='22' y='28' text-anchor='middle' font-family='Segoe UI,Arial,sans-serif' font-size='16' font-weight='700' fill='white'>MP</text></svg>">
              <div class="sm-who">
                <strong class="sm-name">Maya Patel</strong>
                <span class="sm-meta">Head of Product at Northwind · 2h ago</span>
              </div>
              <span class="mv-badge" data-variant="secondary">Following</span>
            </div>
            <p class="sm-body">Our new online refund flow is live: three screens instead of nine, and average turnaround down from 4 days to 36 hours. Huge thanks to the whole team for these six weeks!</p>
            <div class="sm-media" role="img" aria-label="Refund turnaround cut by two-thirds between January and September" data-morph="media">
              <svg viewBox="0 0 400 136" preserveAspectRatio="none" aria-hidden="true">
                <g fill="currentColor" style="color:color-mix(in oklch, var(--mv-accent) 70%, var(--mv-fg))">
                  <rect x="30" y="26" width="34" height="96" rx="5" opacity=".35"/><rect x="84" y="36" width="34" height="86" rx="5" opacity=".4"/>
                  <rect x="138" y="44" width="34" height="78" rx="5" opacity=".45"/><rect x="192" y="60" width="34" height="62" rx="5" opacity=".55"/>
                  <rect x="246" y="78" width="34" height="44" rx="5" opacity=".7"/><rect x="300" y="92" width="34" height="30" rx="5" opacity=".85"/>
                  <rect x="354" y="100" width="34" height="22" rx="5"/>
                </g>
              </svg>
            </div>
            <div class="sm-foot">
              <span class="sm-stats">128 reactions · 24 comments</span>
              <button class="mv-button" data-variant="ghost" data-size="sm" type="button">Like</button>
              <button class="mv-button" data-variant="outline" data-size="sm" type="button">Comment</button>
            </div>
          </div>
        </template>
      </mv-skeleton-morph>
    </article>

    <article class="sm-card">
      <h3>Recent orders</h3>
      <mv-skeleton-morph data-orders>
        <div class="sm-rows">
          <div class="sm-row"><span class="mv-skeleton" style="width:62%"></span><span class="mv-skeleton" style="width:2.5rem"></span><span class="mv-skeleton" style="width:4rem;justify-self:end"></span><span class="mv-skeleton" data-shape="button" style="width:3.75rem;height:1.375rem;justify-self:end"></span></div>
          <div class="sm-row"><span class="mv-skeleton" style="width:48%"></span><span class="mv-skeleton" style="width:2.5rem"></span><span class="mv-skeleton" style="width:3.5rem;justify-self:end"></span><span class="mv-skeleton" data-shape="button" style="width:3.75rem;height:1.375rem;justify-self:end"></span></div>
          <div class="sm-row"><span class="mv-skeleton" style="width:70%"></span><span class="mv-skeleton" style="width:2.5rem"></span><span class="mv-skeleton" style="width:4.25rem;justify-self:end"></span><span class="mv-skeleton" data-shape="button" style="width:3.75rem;height:1.375rem;justify-self:end"></span></div>
          <div class="sm-row"><span class="mv-skeleton" style="width:55%"></span><span class="mv-skeleton" style="width:2.5rem"></span><span class="mv-skeleton" style="width:3.75rem;justify-self:end"></span><span class="mv-skeleton" data-shape="button" style="width:3.75rem;height:1.375rem;justify-self:end"></span></div>
          <div class="sm-row"><span class="mv-skeleton" style="width:64%"></span><span class="mv-skeleton" style="width:2.5rem"></span><span class="mv-skeleton" style="width:4rem;justify-self:end"></span><span class="mv-skeleton" data-shape="button" style="width:3.75rem;height:1.375rem;justify-self:end"></span></div>
        </div>
        <template data-content>
          <div class="sm-rows">
            <div class="sm-row"><span>Olivia Brooks</span><span class="sm-ref">#4821</span><span class="sm-amt">$86.40</span><span class="mv-badge" data-variant="success">Paid</span></div>
            <div class="sm-row"><span>Kenji Tanaka</span><span class="sm-ref">#4822</span><span class="sm-amt">$1,249.00</span><span class="mv-badge" data-variant="warning">Pending</span></div>
            <div class="sm-row"><span>Amara Okafor-Lind</span><span class="sm-ref">#4823</span><span class="sm-amt">$32.90</span><span class="mv-badge" data-variant="success">Paid</span></div>
            <div class="sm-row"><span>Lucas Silva</span><span class="sm-ref">#4824</span><span class="sm-amt">$410.00</span><span class="mv-badge" data-variant="danger">Declined</span></div>
            <div class="sm-row"><span>Aisha Rahman</span><span class="sm-ref">#4825</span><span class="sm-amt">$74.50</span><span class="mv-badge" data-variant="success">Paid</span></div>
          </div>
        </template>
      </mv-skeleton-morph>
    </article>
  </div>
</div>
<script type="module">
  const root = document.getElementById("sm-demo");
  await customElements.whenDefined("mv-skeleton-morph");
  const morphs = [...root.querySelectorAll("mv-skeleton-morph")];
  const btn = root.querySelector("[data-reload]");
  const wait = (ms) => new Promise((r) => setTimeout(r, ms));
  // Fake network: the feed answers first, the orders a little later.
  const load = async () => {
    btn.disabled = true;
    await Promise.all(morphs.map(async (m, i) => { await wait(650 + i * 260); await m.resolve(); }));
    btn.disabled = false;
  };
  btn.addEventListener("click", async () => {
    btn.disabled = true;
    await Promise.all(morphs.map((m) => m.reset()));
    await wait(700);
    load();
  });
  load();
</script>

API

Attributes

NameTypeDefaultDescription
durationnumber1000Duration of one block (ms), excluding stagger.
staggernumber45Delay between successive blocks (ms), in reading order.
loading-labelstringLoading…Accessible status text while loading.
loaded-labelstringOptional announcement once the content has arrived (e.g. “5 orders loaded”).
data-skeleton-foron a skeleton blockName (or selector .x / #x / [x]) of the real element it should become; pairs with data-morph="name" on the content side. Optional: without it, pairing uses kind, order and position.
data-morphon a content elementNames a target, or forces an element (div role=img, card…) to be treated as a block.

Properties

NameTypeDescription
loadingbooleanRead-only: the skeleton is showing.
morphingbooleanRead-only: a morph is in progress.

Methods

NameDescription
resolve(content?) → PromiseMorphs the skeleton into content: Node, DocumentFragment, array of nodes, <template>, or nothing to use <template data-content>. Never an HTML string.
reset(skeleton?) → PromiseReverse morph: the text erases under the bars as they return to the initial skeleton (or <template data-skeleton>, or the one provided).

Events

NameDescription
mv-morphstartdetail: { direction: "in" | "out" }.
mv-morphenddetail: { direction }.

Content structure

NameDescription
(children)The skeleton (.mv-skeleton blocks, .mv-skeleton-text groups), then the content. If a framework swaps the children itself, the change is detected and the morph starts from the skeleton’s remembered positions.
template[data-content] / template[data-skeleton]Content revealed by resolve() with no argument; skeleton restored by reset().

CSS classes

NameDescription
mv-skeleton-morph-layer / -ghostLayer and traveling blocks (data-kind line | block | lone, data-typing while typing).

CSS variables

NameDefaultDescription
--mv-skeleton-morph-caretvar(--mv-accent)Color of the thin caret at the edge of a bar that is typing.

Accessibility

aria-busy="true" while loading, skeleton blocks are aria-hidden; an internal role=status exposes loading-label, then loaded-label if provided. The real content is inserted as soon as the morph starts (readable and focusable immediately); only its painting is hidden during the animation. Reduced motion, element off screen or hidden tab: instant swap, no animation. No HTML string is ever parsed.