Exklusiv
Skeleton Morph <mv-skeleton-morph>
Das Skeleton macht dem Inhalt nicht Platz, es wird zu ihm: Jeder Block wird mit einem echten Element gepaart (data-skeleton-for, dann Art, Reihenfolge und Geometrie) und wandert zu ihm.
So funktioniert es
Das Skeleton macht dem Inhalt nicht Platz, es wird zu ihm: Jeder Block ist einem echten Element zugeordnet (data-skeleton-for, dann Art, Reihenfolge und Geometrie) und wandert zu ihm. Ein Textbalken erreicht seine echte Zeile, nimmt genau deren Breite an und zieht sich dann zurück, während sich der Text dahinter selbst tippt; ein Kreis verwandelt sich in einen Avatar, ein Rechteck in ein Bild oder einen Button. Funktioniert mit resolve(), einem <template> oder von einem Framework ausgetauschten Kindelementen und kann rückwärts abspielen.
| Kategorie | Feedback |
|---|---|
| Typ | Web Component (<mv-skeleton-morph>) |
| Status | stabil |
| Installiert auch | 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
Installation
node scripts/add.mjs skeleton-morph --out ./src/marvelousKI-Agent mit dem MCP-Server von Marvelous UI: install_components({ slugs: ["skeleton-morph"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).
Kopierte Dateien (inklusive Abhängigkeiten): tokens/tokens.css, core/base.css, components/skeleton/skeleton.css, core/dom.js, core/element.js, core/i18n.js, core/motion.js, core/observe.js, components/skeleton-morph/skeleton-morph.js, components/skeleton-morph/skeleton-morph.css.
Verwendung
Referenz-Markup zum Starten und Anpassen mit Attributen, data-* und CSS-Variablen:
<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 Brightwater · 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 | Typ | Standard | Beschreibung |
|---|---|---|---|
duration | number | 1000 | Duration of one block (ms), excluding stagger. |
stagger | number | 45 | Delay between successive blocks (ms), in reading order. |
loading-label | string | Loading… | Accessible status text while loading. |
loaded-label | string | Optional announcement once the content has arrived (e.g. “5 orders loaded”). | |
data-skeleton-for | on 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-morph | on a content element | Names a target, or forces an element (div role=img, card…) to be treated as a block. |
Properties
| Name | Typ | Beschreibung |
|---|---|---|
loading | boolean | Read-only: the skeleton is showing. |
morphing | boolean | Read-only: a morph is in progress. |
Methods
| Name | Beschreibung |
|---|---|
resolve(content?) → Promise | Morphs the skeleton into content: Node, DocumentFragment, array of nodes, <template>, or nothing to use <template data-content>. Never an HTML string. |
reset(skeleton?) → Promise | Reverse morph: the text erases under the bars as they return to the initial skeleton (or <template data-skeleton>, or the one provided). |
Events
| Name | Beschreibung |
|---|---|
mv-morphstart | detail: { direction: "in" | "out" }. |
mv-morphend | detail: { direction }. |
Content structure
| Name | Beschreibung |
|---|---|
(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 | Beschreibung |
|---|---|
mv-skeleton-morph-layer / -ghost | Layer and traveling blocks (data-kind line | block | lone, data-typing while typing). |
CSS variables
| Name | Standard | Beschreibung |
|---|---|---|
--mv-skeleton-morph-caret | var(--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. Forced colors: the travelling blocks are drawn in GrayText. Known limits: pairing without data-skeleton-for is a heuristic (kind, order, position), so a placeholder layout far from the real one pairs loosely; during a morph the text targets carry an inline mask and the component clips its own overflow (previous inline values are given back at the end); the static skeleton blocks follow the skeleton component.