ExklusivBeta

Skeleton Morph <mv-skeleton-morph>

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.

KategorieFeedback
TypWeb Component (<mv-skeleton-morph>)
StatusBeta
Installiert auchskeleton
Keywordsexclusive, 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/marvelous

KI-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, 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.

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

NameTypDefaultDescription
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

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

Diese Seite wurde mit KI übersetzt. Übersetzungsfehler melden