Exclusive beta
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.
Category Feedback Type Web Component (<mv-skeleton-morph>) Status beta Also installs skeleton Keywords exclusive, skeleton, loading, morph, flip, shared-element, placeholder, chargement, transition, async
When to use
Loaded content should visibly grow out of its placeholders, each block traveling to its real element A showcase or premium app wants text to type in and avatars or images to morph from their skeletons Content swapped by a framework or a template should transition smoothly from its loading state
Avoid when
Many lists load at once or loading is frequent; plain placeholders with a fade are cheaper → use Skeleton instead The placeholder layout differs a lot from the real content, so blocks have nothing to match
Get Skeleton Morph. 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 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
Name Type Default Description durationnumber 1000Duration of one block (ms), excluding stagger. staggernumber 45Delay between successive blocks (ms), in reading order. loading-labelstring Loading…Accessible status text while loading. loaded-labelstring Optional announcement once the content has arrived (e.g. “5 orders loaded”). data-skeleton-foron a skeleton block Name (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 element Names a target, or forces an element (div role=img, card…) to be treated as a block.
Properties
Name Type Description loadingboolean Read-only: the skeleton is showing. morphingboolean Read-only: a morph is in progress.
Methods
Name Description 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
Name Description mv-morphstartdetail: { direction: "in" | "out" }. mv-morphenddetail: { direction }.
Content structure
Name Description (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
Name Description mv-skeleton-morph-layer / -ghostLayer and traveling blocks (data-kind line | block | lone, data-typing while typing).
CSS variables
Name Default Description --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.