Exclusivebeta

Lacuna — <mv-lacuna>

In-place, gradual deletion for lists and grids, a replacement for undo toasts that holds up when you delete several things in a row. An erased item does not vanish: it stays where it is for a few seconds (6 by default, each item on its own clock) and visibly comes apart, letters dropping out one by one in a deterministic order, images greying and blurring, edges feathering, the whole item shrinking a little, with an “Erasing… Click to keep” pill and a thin remaining-time line. Clicking it (or Enter, Space, Ctrl/Cmd+Z for the latest) keeps it intact with a re-materialization, and hovering holds its clock; otherwise it collapses (height in lists, FLIP of the remaining tiles in grids), leaves the DOM and fires mv-erase so the app commits the real delete. A sticky status bar sums it up (“Erasing 3 items · Keep all · Erase now”), pending erasures are committed or kept when the page is left, and the real DOM text is never rewritten, so a rescue is lossless.

CategoryData display
TypeWeb Component (<mv-lacuna>)
Statusbeta
Keywordsexclusive, culture, delete, undo, soft-delete, deferred-delete, rescue, undo-toast-alternative, list, grid, files, notes, photos, fade, dissolve, text-effect, flip, keyboard

When to use

Avoid when

Install

node scripts/add.mjs lacuna --out ./src/marvelous

AI agent with the Marvelous UI MCP server: install_components({ slugs: ["lacuna"], 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/focus.js, core/motion.js, components/lacuna/lacuna.js, components/lacuna/lacuna.css.

Usage

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

<div id="lc-demo" style="width:min(100%,46rem);margin-inline:auto">
  <style>
    #lc-demo { display:grid; gap:1.75rem }
    #lc-demo .lc-card { border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-surface); box-shadow:var(--mv-shadow-xs) }
    #lc-demo .lc-head { display:flex; align-items:center; justify-content:space-between; gap:.75rem 1rem; flex-wrap:wrap; padding:1rem 1.25rem; border-bottom:1px solid var(--mv-border) }
    #lc-demo .lc-head h3 { margin:0; font-size:.9375rem; letter-spacing:-.01em }
    #lc-demo .lc-head p { margin:.125rem 0 0; color:var(--mv-fg-muted); font-size:.8125rem }
    #lc-demo .lc-actions { display:flex; gap:.5rem; flex-wrap:wrap }
    #lc-demo mv-lacuna { padding-bottom:.75rem }
    #lc-demo .lc-list { list-style:none; margin:0; padding:.375rem .5rem 0 }
    #lc-demo .lc-row { display:flex; align-items:center; gap:.875rem; padding:.625rem .75rem; border-radius:var(--mv-radius-lg); outline:none; transition:background-color var(--mv-duration-fast) var(--mv-ease-out) }
    #lc-demo .lc-row:hover { background:var(--mv-bg-subtle) }
    #lc-demo .lc-row:focus-visible { box-shadow:var(--mv-focus-ring) }
    #lc-demo .lc-icon { display:grid; place-items:center; flex:none; width:2.25rem; height:2.25rem; border-radius:var(--mv-radius-md); background:color-mix(in oklch, var(--lc-tone) 14%, var(--mv-surface)); color:var(--lc-tone) }
    #lc-demo .lc-icon svg { width:1.05rem; height:1.05rem }
    #lc-demo .lc-body { flex:1; min-width:0 }
    #lc-demo .lc-body strong { display:block; font-size:.875rem; font-weight:600; white-space:nowrap; overflow:hidden; text-overflow:ellipsis }
    #lc-demo .lc-body > span { display:block; margin-top:.125rem; color:var(--mv-fg-muted); font-size:.75rem }
    #lc-demo .lc-del { flex:none; opacity:.55; transition:opacity var(--mv-duration-fast) var(--mv-ease-out) }
    #lc-demo .lc-row:hover .lc-del, #lc-demo .lc-row:focus-within .lc-del { opacity:1 }
    #lc-demo .lc-foot { display:flex; align-items:center; justify-content:space-between; gap:.75rem; flex-wrap:wrap; padding:.75rem 1.25rem; border-top:1px solid var(--mv-border); color:var(--mv-fg-muted); font-size:.8125rem }
    #lc-demo .lc-foot kbd { font:500 .6875rem/1 var(--mv-font-mono); padding:.15rem .35rem; border:1px solid var(--mv-border-strong); border-bottom-width:2px; border-radius:var(--mv-radius-xs); color:var(--mv-fg) }
    #lc-demo .lc-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(10rem,1fr)); gap:.75rem; padding:1rem 1.25rem 0 }
    #lc-demo .lc-photo { position:relative; margin:0; border-radius:var(--mv-radius-lg); outline:none }
    #lc-demo .lc-photo:focus-visible { box-shadow:var(--mv-focus-ring) }
    #lc-demo .lc-img { aspect-ratio:4/3; border-radius:var(--mv-radius-lg); box-shadow:inset 0 0 0 1px oklch(0 0 none / .06) }
    #lc-demo .lc-photo figcaption { padding:.5rem .125rem .5rem }
    #lc-demo .lc-photo strong { display:block; font-size:.8125rem; font-weight:600 }
    #lc-demo .lc-photo figcaption > span { display:block; color:var(--mv-fg-muted); font-size:.75rem }
    #lc-demo .lc-photo .lc-x { position:absolute; top:.4rem; right:.4rem; display:grid; place-items:center; width:1.75rem; height:1.75rem; border:0; border-radius:var(--mv-radius-full); background:oklch(0.15 0 none / .55); color:white; cursor:pointer; opacity:0; transition:opacity var(--mv-duration-fast) var(--mv-ease-out); backdrop-filter:blur(6px) }
    #lc-demo .lc-photo:hover .lc-x, #lc-demo .lc-photo:focus-within .lc-x { opacity:1 }
    #lc-demo .lc-photo .lc-x:focus-visible { opacity:1; outline:none; box-shadow:var(--mv-focus-ring) }
    #lc-demo .lc-photo .lc-x svg { width:.9rem; height:.9rem }
    @media (hover:none) { #lc-demo .lc-photo .lc-x { opacity:1 } }
  </style>

  <!-- 1 · Files list -->
  <section class="lc-card" aria-labelledby="lc-files-title">
    <div class="lc-head">
      <div>
        <h3 id="lc-files-title">Recent files</h3>
        <p>Delete a few in a row, then click one before it fades to keep it.</p>
      </div>
      <div class="lc-actions">
        <button class="mv-button" data-variant="outline" data-size="sm" type="button" id="lc-three">Erase three at once</button>
        <button class="mv-button" data-variant="ghost" data-size="sm" type="button" id="lc-restore">Restore demo</button>
      </div>
    </div>
    <mv-lacuna id="lc-files" duration="6s" label="Files being erased">
      <ul class="lc-list" id="lc-files-list">
        <li class="lc-row" data-lacuna-item data-id="f-104" tabindex="0" style="--lc-tone:var(--mv-info)">
          <span class="lc-icon" aria-hidden="true"><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 2v6h6M9 13h6M9 17h4"/></svg></span>
          <span class="lc-body"><strong>Q3 roadmap draft</strong><span>Doc · Edited 2 hours ago by Priya Raman</span></span>
          <button class="mv-button lc-del" data-variant="ghost" data-size="icon" type="button" data-lacuna-erase aria-label="Delete Q3 roadmap draft"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 7h16M10 11v6M14 11v6M5 7l1 12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2l1-12M9 7V4h6v3"/></svg></button>
        </li>
        <li class="lc-row" data-lacuna-item data-id="f-099" tabindex="0" style="--lc-tone:var(--mv-success)">
          <span class="lc-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18M3 15h18M9 3v18"/></svg></span>
          <span class="lc-body"><strong>Budget 2027 — v2.xlsx</strong><span>Spreadsheet · 184 KB · Shared with Finance</span></span>
          <button class="mv-button lc-del" data-variant="ghost" data-size="icon" type="button" data-lacuna-erase aria-label="Delete Budget 2027 — v2.xlsx"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 7h16M10 11v6M14 11v6M5 7l1 12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2l1-12M9 7V4h6v3"/></svg></button>
        </li>
        <li class="lc-row" data-lacuna-item data-id="f-097" tabindex="0" style="--lc-tone:var(--mv-accent)">
          <span class="lc-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z"/></svg></span>
          <span class="lc-body"><strong>Interview notes — Maya Patel</strong><span>Note · Yesterday at 4:12 PM</span></span>
          <button class="mv-button lc-del" data-variant="ghost" data-size="icon" type="button" data-lacuna-erase aria-label="Delete Interview notes — Maya Patel"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 7h16M10 11v6M14 11v6M5 7l1 12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2l1-12M9 7V4h6v3"/></svg></button>
        </li>
        <li class="lc-row" data-lacuna-item data-id="f-093" tabindex="0" style="--lc-tone:var(--mv-warning)">
          <span class="lc-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-5-5L5 21"/></svg></span>
          <span class="lc-body"><strong>Brand refresh moodboard</strong><span>Board · 36 images · Edited Monday</span></span>
          <button class="mv-button lc-del" data-variant="ghost" data-size="icon" type="button" data-lacuna-erase aria-label="Delete Brand refresh moodboard"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 7h16M10 11v6M14 11v6M5 7l1 12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2l1-12M9 7V4h6v3"/></svg></button>
        </li>
        <li class="lc-row" data-lacuna-item data-id="f-090" tabindex="0" style="--lc-tone:var(--mv-info)">
          <span class="lc-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/></svg></span>
          <span class="lc-body"><strong>Team offsite agenda — Lisbon</strong><span>Doc · Oct 14–16 · 3 comments</span></span>
          <button class="mv-button lc-del" data-variant="ghost" data-size="icon" type="button" data-lacuna-erase aria-label="Delete Team offsite agenda — Lisbon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 7h16M10 11v6M14 11v6M5 7l1 12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2l1-12M9 7V4h6v3"/></svg></button>
        </li>
        <li class="lc-row" data-lacuna-item data-id="f-086" tabindex="0" style="--lc-tone:var(--mv-success)">
          <span class="lc-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 3v18h18"/><path d="m7 15 4-4 3 3 5-6"/></svg></span>
          <span class="lc-body"><strong>Pricing experiment results</strong><span>Report · $12.4k uplift · Sep 2</span></span>
          <button class="mv-button lc-del" data-variant="ghost" data-size="icon" type="button" data-lacuna-erase aria-label="Delete Pricing experiment results"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 7h16M10 11v6M14 11v6M5 7l1 12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2l1-12M9 7V4h6v3"/></svg></button>
        </li>
        <li class="lc-row" data-lacuna-item data-id="f-081" tabindex="0" style="--lc-tone:var(--mv-accent)">
          <span class="lc-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 7h-9M14 17H5"/><circle cx="17" cy="17" r="3"/><circle cx="7" cy="7" r="3"/></svg></span>
          <span class="lc-body"><strong>Release notes 4.2</strong><span>Draft · 1,240 words · Not published</span></span>
          <button class="mv-button lc-del" data-variant="ghost" data-size="icon" type="button" data-lacuna-erase aria-label="Delete Release notes 4.2"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 7h16M10 11v6M14 11v6M5 7l1 12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2l1-12M9 7V4h6v3"/></svg></button>
        </li>
        <li class="lc-row" data-lacuna-item data-id="f-077" tabindex="0" style="--lc-tone:var(--mv-fg-muted)">
          <span class="lc-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 8v13H3V8M1 3h22v5H1zM10 12h4"/></svg></span>
          <span class="lc-body"><strong>Old logo exports.zip</strong><span>Archive · 48 MB · Uploaded in March</span></span>
          <button class="mv-button lc-del" data-variant="ghost" data-size="icon" type="button" data-lacuna-erase aria-label="Delete Old logo exports.zip"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 7h16M10 11v6M14 11v6M5 7l1 12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2l1-12M9 7V4h6v3"/></svg></button>
        </li>
      </ul>
    </mv-lacuna>
    <div class="lc-foot">
      <span id="lc-log" aria-live="off">Focus a row and press <kbd>Delete</kbd>; <kbd>Ctrl</kbd> <kbd>Z</kbd> keeps the latest.</span>
      <span id="lc-count">8 files</span>
    </div>
  </section>

  <!-- 2 · Photo grid -->
  <section class="lc-card" aria-labelledby="lc-photos-title">
    <div class="lc-head">
      <div>
        <h3 id="lc-photos-title">Trip to the coast · 8 photos</h3>
        <p>Hovering a fading photo holds its clock.</p>
      </div>
    </div>
    <mv-lacuna id="lc-photos" duration="5s" layout="grid" label="Photos being erased">
      <div class="lc-grid" id="lc-photos-grid">
        <figure class="lc-photo" data-lacuna-item data-id="p-01" tabindex="0"><div class="lc-img" data-lacuna-media style="background:linear-gradient(180deg,oklch(0.82 0.08 230) 0%,oklch(0.9 0.05 80) 52%,oklch(0.55 0.1 220) 53%,oklch(0.42 0.09 235) 100%)"></div><figcaption><strong>Harbor at noon</strong><span>Sep 12 · 4.1 MB</span></figcaption><button class="lc-x" type="button" data-lacuna-erase aria-label="Delete Harbor at noon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true"><path d="M18 6 6 18M6 6l12 12"/></svg></button></figure>
        <figure class="lc-photo" data-lacuna-item data-id="p-02" tabindex="0"><div class="lc-img" data-lacuna-media style="background:radial-gradient(circle at 70% 38%,oklch(0.93 0.1 85) 0 7%,transparent 8%),linear-gradient(180deg,oklch(0.62 0.16 30) 0%,oklch(0.78 0.14 60) 48%,oklch(0.36 0.08 280) 49%,oklch(0.26 0.06 270) 100%)"></div><figcaption><strong>Sunset from the pier</strong><span>Sep 12 · 3.8 MB</span></figcaption><button class="lc-x" type="button" data-lacuna-erase aria-label="Delete Sunset from the pier"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true"><path d="M18 6 6 18M6 6l12 12"/></svg></button></figure>
        <figure class="lc-photo" data-lacuna-item data-id="p-03" tabindex="0"><div class="lc-img" data-lacuna-media style="background:linear-gradient(160deg,transparent 55%,oklch(0.48 0.09 150) 56%),linear-gradient(200deg,transparent 48%,oklch(0.58 0.1 140) 49%),linear-gradient(180deg,oklch(0.88 0.04 220),oklch(0.8 0.05 200))"></div><figcaption><strong>Cliff trail</strong><span>Sep 13 · 5.2 MB</span></figcaption><button class="lc-x" type="button" data-lacuna-erase aria-label="Delete Cliff trail"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true"><path d="M18 6 6 18M6 6l12 12"/></svg></button></figure>
        <figure class="lc-photo" data-lacuna-item data-id="p-04" tabindex="0"><div class="lc-img" data-lacuna-media style="background:radial-gradient(ellipse at 50% 120%,oklch(0.85 0.06 85) 0 38%,transparent 39%),linear-gradient(180deg,oklch(0.7 0.1 230),oklch(0.52 0.11 225))"></div><figcaption><strong>Dunes, low tide</strong><span>Sep 13 · 2.9 MB</span></figcaption><button class="lc-x" type="button" data-lacuna-erase aria-label="Delete Dunes, low tide"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true"><path d="M18 6 6 18M6 6l12 12"/></svg></button></figure>
        <figure class="lc-photo" data-lacuna-item data-id="p-05" tabindex="0"><div class="lc-img" data-lacuna-media style="background:repeating-linear-gradient(90deg,oklch(0.72 0.13 25) 0 14%,oklch(0.86 0.08 90) 14% 28%,oklch(0.62 0.1 200) 28% 42%,oklch(0.9 0.03 80) 42% 56%)"></div><figcaption><strong>Beach huts</strong><span>Sep 14 · 4.6 MB</span></figcaption><button class="lc-x" type="button" data-lacuna-erase aria-label="Delete Beach huts"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true"><path d="M18 6 6 18M6 6l12 12"/></svg></button></figure>
        <figure class="lc-photo" data-lacuna-item data-id="p-06" tabindex="0"><div class="lc-img" data-lacuna-media style="background:radial-gradient(circle at 30% 30%,oklch(0.96 0.02 90) 0 10%,transparent 40%),linear-gradient(180deg,oklch(0.8 0.02 240),oklch(0.62 0.03 230))"></div><figcaption><strong>Morning fog</strong><span>Sep 14 · 3.1 MB</span></figcaption><button class="lc-x" type="button" data-lacuna-erase aria-label="Delete Morning fog"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true"><path d="M18 6 6 18M6 6l12 12"/></svg></button></figure>
        <figure class="lc-photo" data-lacuna-item data-id="p-07" tabindex="0"><div class="lc-img" data-lacuna-media style="background:radial-gradient(circle at 50% 55%,oklch(0.78 0.14 60) 0 16%,oklch(0.6 0.12 45) 17% 22%,transparent 23%),linear-gradient(135deg,oklch(0.42 0.05 60),oklch(0.3 0.04 50))"></div><figcaption><strong>Café on the corner</strong><span>Sep 15 · 2.4 MB</span></figcaption><button class="lc-x" type="button" data-lacuna-erase aria-label="Delete Café on the corner"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true"><path d="M18 6 6 18M6 6l12 12"/></svg></button></figure>
        <figure class="lc-photo" data-lacuna-item data-id="p-08" tabindex="0"><div class="lc-img" data-lacuna-media style="background:linear-gradient(180deg,oklch(0.24 0.05 270) 0%,oklch(0.36 0.08 290) 60%,oklch(0.55 0.12 330) 100%),radial-gradient(circle,white 1px,transparent 1px)"></div><figcaption><strong>Night drive home</strong><span>Sep 15 · 3.3 MB</span></figcaption><button class="lc-x" type="button" data-lacuna-erase aria-label="Delete Night drive home"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true"><path d="M18 6 6 18M6 6l12 12"/></svg></button></figure>
      </div>
    </mv-lacuna>
  </section>

  <script type="module">
    await customElements.whenDefined("mv-lacuna");
    const files = document.getElementById("lc-files");
    const photos = document.getElementById("lc-photos");
    const log = document.getElementById("lc-log");
    const count = document.getElementById("lc-count");
    const templates = new Map([
      [files, document.getElementById("lc-files-list").cloneNode(true)],
      [photos, document.getElementById("lc-photos-grid").cloneNode(true)],
    ]);
    const syncCount = () => { const n = files.items.length; count.textContent = `${n} file${n === 1 ? "" : "s"}`; };
    for (const el of [files, photos]) {
      el.addEventListener("mv-erase", (e) => { log.textContent = `Deleted “${e.detail.label}” (${e.detail.data.id}) — committed to the server.`; syncCount(); });
      el.addEventListener("mv-rescue", (e) => { log.textContent = `Kept “${e.detail.label}”.`; });
    }
    document.getElementById("lc-three").addEventListener("click", () => {
      const list = files.items.filter((item) => !files.pending.includes(item));
      [1, 3, 4].map((i) => list[i]).filter(Boolean).forEach((item, k) => setTimeout(() => files.erase(item), k * 220));
    });
    document.getElementById("lc-restore").addEventListener("click", () => {
      for (const [el, tpl] of templates) {
        el.rescueAll();
        const current = el.querySelector(".lc-list, .lc-grid");
        current.replaceWith(tpl.cloneNode(true));
      }
      syncCount();
      log.textContent = "Demo restored: 8 files and 8 photos.";
    });
  </script>
</div>

Cultural reference

Eternal Sunshine of the Spotless Mind — Michel Gondry (screenplay by Charlie Kaufman) (2004, film). Memories are erased one by one while he relives them, fading and falling apart in front of him, and he tries to hide the one he wants to keep before it disappears. Here deleted items dissolve in place over a few seconds and can be rescued by simply grabbing them before they are gone.

API

Attributes

NameTypeDefaultDescription
durationtime6sHow long an erased item stays and comes apart before it is committed: 6s, 4500ms, or a bare number of milliseconds. Changing it applies to pending items too.
pause-on-hover"true" | "false"trueHovering an erasing item holds its clock (the pill turns to “Paused · Click to keep”). The hover that was already there when the erase started (the pointer on the Delete button) does not count: the pointer has to leave and come back.
on-leavecommit | keepcommitWhat happens to pending erasures on pagehide (navigation, reload, tab close, back-forward cache) or when the component is removed from the DOM: commit finishes them synchronously and emits mv-erase for each (send them with sendBeacon/keepalive); keep restores them silently.
layoutlist | gridHow an erased item leaves: list collapses its height, grid fades it and slides the remaining tiles into the gap (FLIP), with a centered pill. Auto-detected when omitted (another item on the same row means grid).
labelstringPending erasuresAccessible name of the status bar region.
data-lacuna-itemmarker attributeMarks the items. Without any, the direct element children of <mv-lacuna> are the items.
data-lacuna-erasemarker attributeOn a button (or link) inside an item: starts erasing that item. Clicking it again while the item erases keeps it.
data-lacuna-mediamarker attributeInside an item: an element treated as an image (greys and blurs faster), for CSS backgrounds or custom media. <img>, <video>, <picture> and <canvas> are detected on their own.
data-lacuna-labelstringOn an item: name used in announcements and events. Otherwise the first heading, <strong>, <figcaption> or [data-lacuna-title], else the item text (60 characters max).
data-lacuna-keep-textmarker attributeOn an element inside an item: its text is left alone (no letter drop), e.g. for text a framework re-renders during the erasure.
data-lacuna-state / data-lacuna-paused / data-lacuna-layoutset by the componentOn items: erasing | erased, while the clock is held, list | grid. The host gets data-erasing while anything is pending.

Properties

NameTypeDescription
itemsElement[]Read-only: the items currently managed (marked items, or direct children).
pendingElement[]Read-only: items currently erasing, oldest first.

Methods

NameDescription
erase(item | index)Starts erasing an item. Returns false if it is already erasing or if mv-erase-start was cancelled.
rescue(item | index)Keeps an erasing item: its text and attributes are restored as they were, with a short re-materialization. Returns false if it was not erasing.
rescueAll()Keeps every pending item (“Keep all”). Returns how many.
commitAll()Finishes every pending erasure immediately (“Erase now”). Returns how many.

Events

NameDescription
mv-erase-startCancelable, before an item starts erasing. detail: { item, data (the item's dataset, without data-lacuna-*), label }. preventDefault() refuses it (e.g. item locked).
mv-rescueAn item was kept. detail: { item, data, label }.
mv-eraseAn item completed, collapsed and was removed from the DOM: commit the real delete now. detail: { item (detached, text intact), data, label }.

Content structure

NameDescription
(content)A list or grid: the items themselves as direct children, or any structure (ul, div grid…) with [data-lacuna-item] elements. The status bar and a visually hidden live region are appended after it.

CSS classes

NameDescription
mv-lacuna-uiOverlay added to an erasing item: .mv-lacuna-pill (icon + .mv-lacuna-pill-text) and .mv-lacuna-line (.mv-lacuna-line-fill, scaled to the remaining time).
mv-lacuna-text / -char / -realTemporary wrapper of each text node: the aria-hidden twin made of .mv-lacuna-char letters ([data-gone] once dropped) and the visually hidden real node.
mv-lacuna-barSticky status region: .mv-lacuna-bar-dot, .mv-lacuna-bar-count, .mv-lacuna-keep-all and .mv-lacuna-erase-now buttons.

CSS variables

NameTypeDefaultDescription
--mv-lacuna-colorvar(--mv-accent)Progress line, paused pill and status dot.
--mv-lacuna-feather12pxHow far the edges of the content dissolve at the end.
--mv-lacuna-pill-insetvar(--mv-space-3)Distance of the pill from the inline end of a list row.
--_poutputSet on an erasing item: progress 0–1, usable for your own degradation styles.

Accessibility

Items keep their own role and semantics; while erasing, an item gets aria-describedby pointing to “Erasing. Press Enter to keep it.” and becomes focusable (tabindex=-1) if it was not, and focus stays where it was (usually its Delete button), so Enter or Space on it, or anywhere inside it, keeps it; Delete or Backspace on a focused item (outside text fields) starts erasing it; Ctrl/Cmd+Z keeps the most recent erasure page-wide, never while typing in a field. A polite role=status announces “Erasing ‘Q3 roadmap draft’. Press Enter to keep it.” (or “Press Control Z” when focus is elsewhere), “Kept ‘…’.”, “Erased ‘…’.” and the bulk results (“Kept 3 items.”). The screen-reader text is the real, untouched text node (moved into a visually hidden span) while the dropping letters are an aria-hidden twin, so assistive tech keeps reading the true content until the end. When an item that holds focus leaves, focus moves to the next item (its Delete button when that is where it was), else the previous one, else the list. The status bar is a labelled region with two real buttons (“Keep all”, “Erase now”) and is never the only way to act. A double click on Delete does not undo itself. Touch: tap to keep (the pill says “Tap to keep”). Reduced motion (OS setting or data-motion="reduce"): no letter drop, blur, shrink, feathering or FLIP, only a fade and the remaining-time line, and the item disappears without a collapse; the clock itself still runs.