Exclusivebeta

Bloodstain — <mv-bloodstain>

Form draft recovery that remembers exactly where you were: wrap any <form> (or region with fields) and its values are autosaved (debounced, localStorage or sessionStorage) together with the last focused field and caret position. When the session dies (crash, reload, closed tab, failed submit), the next mount shows a small pulsing drop mark on that very field (“You left off here · 12 min ago”) and a compact banner, “Recover your unsaved progress? 7 fields · 12 min ago”; Recover restores everything in one touch, dispatches input/change so frameworks stay in sync, focuses the field and puts the caret back. Nothing is auto-restored, and typing into a saved field without recovering replaces the old draft for good (forfeit="never" opts out). Passwords, card data, one-time codes, hidden and file inputs are never stored; a successful submit or clear() wipes the draft; drafts expire after a TTL.

CategoryForms
TypeWeb Component (<mv-bloodstain>)
Statusbeta
Also installsbutton
Keywordsexclusive, culture, form, draft, autosave, recovery, restore, unsaved-changes, crash, reload, localstorage, session-storage, resume, caret, persistence, form-state

When to use

Avoid when

Install

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

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

Usage

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

<div id="bs-demo" style="width:min(100%,60rem);margin-inline:auto">
  <style>
    #bs-demo .bs-grid { display:grid; grid-template-columns:minmax(0,1fr) 17rem; gap:1.25rem; align-items:start }
    #bs-demo .bs-card { min-width:0; padding:1.25rem 1.375rem 1.375rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-surface); box-shadow:var(--mv-shadow-sm) }
    #bs-demo .bs-head { display:flex; align-items:flex-start; justify-content:space-between; gap:.75rem; margin:0 0 1.125rem }
    #bs-demo .bs-head h3 { margin:0; font-size:1.0625rem; letter-spacing:-.01em }
    #bs-demo .bs-head p { margin:.1875rem 0 0; color:var(--mv-fg-muted); font-size:.8125rem }
    #bs-demo .bs-saved { flex:none; color:var(--mv-fg-subtle); font-size:.75rem; white-space:nowrap; padding-top:.1875rem }
    #bs-demo .bs-saved[data-on] { color:var(--mv-fg-muted) }
    #bs-demo .bs-saved[data-on]::before { content:""; display:inline-block; width:.4375rem; height:.4375rem; margin-inline-end:.375rem; border-radius:50%; background:var(--mv-success); vertical-align:.0625rem }
    #bs-demo .bs-radios { display:flex; gap:.5rem 1.25rem; flex-wrap:wrap; padding-top:.25rem }
    #bs-demo .bs-foot { display:flex; align-items:center; justify-content:space-between; gap:.75rem; flex-wrap:wrap; margin-top:1.25rem; padding-top:1rem; border-top:1px solid var(--mv-border) }
    #bs-demo .bs-foot small { color:var(--mv-fg-subtle); font-size:.75rem }
    #bs-demo .bs-side { display:grid; gap:1rem; position:sticky; top:1rem }
    #bs-demo .bs-panel { padding:1rem 1.125rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-bg-subtle) }
    #bs-demo .bs-panel h4 { margin:0 0 .625rem; font-size:.8125rem }
    #bs-demo .bs-steps { margin:0 0 1rem; padding-inline-start:1.125rem; color:var(--mv-fg-muted); font-size:.8125rem; line-height:1.5; display:grid; gap:.375rem }
    #bs-demo .bs-steps b { color:var(--mv-fg); font-weight:600 }
    #bs-demo .bs-crash { width:100% }
    #bs-demo .bs-chips { display:flex; flex-wrap:wrap; gap:.375rem; margin:0 0 .75rem; padding:0; list-style:none }
    #bs-demo .bs-chips:empty::before { content:"Nothing saved yet"; color:var(--mv-fg-subtle); font-size:.75rem }
    #bs-demo .bs-label { margin:0 0 .375rem; color:var(--mv-fg-subtle); font-size:.6875rem; font-weight:600; letter-spacing:.05em; text-transform:uppercase }
    #bs-demo .bs-loading { display:grid; place-items:center; min-height:34rem; color:var(--mv-fg-muted); font-size:.875rem }
    #bs-demo .bs-done { display:grid; justify-items:center; gap:.5rem; padding:3rem 1rem; text-align:center }
    #bs-demo .bs-done p { margin:0; color:var(--mv-fg-muted); font-size:.875rem }
    #bs-demo [hidden] { display:none }
    @media (max-width:52rem) {
      #bs-demo .bs-grid { grid-template-columns:minmax(0,1fr) }
      #bs-demo .bs-side { position:static }
    }
  </style>

  <div class="bs-grid">
    <div class="bs-card" id="bs-slot"></div>

    <aside class="bs-side" aria-label="Try it">
      <div class="bs-panel">
        <h4>Try it</h4>
        <ol class="bs-steps">
          <li>The form is half filled, as if you had been typing. Edit anything you like.</li>
          <li><b>Simulate a crash</b>: the form is torn down and loads again, empty.</li>
          <li>Recover it in one click, or start typing in a saved field to lose it for good.</li>
        </ol>
        <button type="button" class="mv-button bs-crash" id="bs-crash">
          <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12a9 9 0 1 1-2.6-6.4"/><path d="M21 4v5h-5"/></svg>
          Simulate crash
        </button>
      </div>
      <div class="bs-panel">
        <h4>What the draft stores</h4>
        <p class="bs-label">Saved</p>
        <ul class="bs-chips" id="bs-stored"></ul>
        <p class="bs-label">Never stored</p>
        <ul class="bs-chips">
          <li><span class="mv-badge" data-variant="outline">password</span></li>
          <li><span class="mv-badge" data-variant="outline">applicant ID (hidden)</span></li>
        </ul>
      </div>
    </aside>
  </div>

  <template id="bs-template">
    <mv-bloodstain key="mv-demo:job-application" debounce="300">
      <form id="bs-form" novalidate>
        <div class="bs-head">
          <div>
            <h3>Senior Product Designer</h3>
            <p>Remote, United States · Full time</p>
          </div>
          <span class="bs-saved" id="bs-saved" aria-live="polite">Not saved yet</span>
        </div>
        <input type="hidden" name="applicant_id" value="apl_7Q2K9">
        <div class="mv-field-group">
          <div class="mv-field-row">
            <div class="mv-field">
              <label class="mv-label" for="bs-name" data-required>Full name</label>
              <input class="mv-input" id="bs-name" name="name" autocomplete="name" required>
            </div>
            <div class="mv-field">
              <label class="mv-label" for="bs-email" data-required>Email</label>
              <input class="mv-input" id="bs-email" name="email" type="email" autocomplete="email" required>
            </div>
          </div>
          <div class="mv-field-row">
            <div class="mv-field">
              <label class="mv-label" for="bs-phone" data-optional="optional">Phone</label>
              <input class="mv-input" id="bs-phone" name="phone" type="tel" autocomplete="tel">
            </div>
            <div class="mv-field">
              <label class="mv-label" for="bs-site">Portfolio URL</label>
              <input class="mv-input" id="bs-site" name="portfolio" type="url" placeholder="https://">
            </div>
          </div>
          <div class="mv-field-row">
            <div class="mv-field">
              <label class="mv-label" for="bs-notice">Available to start</label>
              <select class="mv-select" id="bs-notice" name="notice">
                <option value="">Select…</option>
                <option value="now">Immediately</option>
                <option value="2w">In 2 weeks</option>
                <option value="1m">In 1 month</option>
                <option value="2m">In 2 months or more</option>
              </select>
            </div>
            <fieldset class="mv-field" style="border:0;margin:0;padding:0;min-width:0">
              <legend class="mv-label">Work arrangement</legend>
              <div class="bs-radios">
                <label class="mv-choice"><input type="radio" class="mv-radio" name="arrangement" value="remote"> Remote</label>
                <label class="mv-choice"><input type="radio" class="mv-radio" name="arrangement" value="hybrid"> Hybrid</label>
                <label class="mv-choice"><input type="radio" class="mv-radio" name="arrangement" value="onsite"> On-site</label>
              </div>
            </fieldset>
          </div>
          <div class="mv-field">
            <label class="mv-label" for="bs-letter">Why this role?</label>
            <textarea class="mv-textarea" id="bs-letter" name="letter" style="--mv-textarea-min-rows:4" placeholder="A few lines about your work and what draws you here…"></textarea>
          </div>
          <div class="mv-field">
            <label class="mv-label" for="bs-pass">Create a password for your candidate account</label>
            <input class="mv-input" id="bs-pass" name="password" type="password" autocomplete="new-password" aria-describedby="bs-pass-hint">
            <p class="mv-field-description" id="bs-pass-hint">Never saved in the draft: you’ll type it again after a crash.</p>
          </div>
          <label class="mv-choice"><input type="checkbox" class="mv-checkbox" name="updates" id="bs-updates"> Let me know about similar roles</label>
        </div>
        <div class="bs-foot">
          <small>Drafts are kept on this device for 24 hours.</small>
          <button type="submit" class="mv-button" id="bs-submit">Submit application</button>
        </div>
      </form>
    </mv-bloodstain>
  </template>

  <script type="module">
    const root = document.getElementById("bs-demo");
    const slot = document.getElementById("bs-slot");
    const tpl = document.getElementById("bs-template");
    const stored = document.getElementById("bs-stored");
    const KEY = "mv-demo:job-application";
    const LABELS = { name: "name", email: "email", phone: "phone", portfolio: "portfolio", notice: "start date", arrangement: "arrangement", letter: "cover note", updates: "updates" };
    let host = null;
    let savedAt = 0;
    let ticker = 0;

    const readDraft = () => { try { return JSON.parse(localStorage.getItem(KEY) || "null"); } catch { return null; } };
    const showStored = () => {
      const d = readDraft();
      stored.replaceChildren(...Object.entries(d?.fields ?? {})
        .filter(([, v]) => v !== "" && v !== null && v !== false && !(Array.isArray(v) && !v.length))
        .map(([k]) => { const li = document.createElement("li"); const b = document.createElement("span"); b.className = "mv-badge"; b.dataset.variant = "secondary"; b.textContent = LABELS[k] ?? k; li.append(b); return li; }));
    };
    const tick = () => {
      const s = host?.querySelector("#bs-saved");
      if (!s || !savedAt) return;
      const sec = Math.round((Date.now() - savedAt) / 1000);
      s.textContent = `Draft saved · ${sec < 5 ? "just now" : sec < 60 ? `${sec}s ago` : `${Math.round(sec / 60)} min ago`}`;
      s.dataset.on = "";
    };

    // Listeners live on the stable slot: component events bubble.
    slot.addEventListener("mv-save", (e) => { savedAt = e.detail.t; tick(); showStored(); });
    slot.addEventListener("mv-stain", () => { const s = host?.querySelector("#bs-saved"); if (s) s.textContent = "Unsaved draft found"; });
    slot.addEventListener("mv-forfeit", () => setTimeout(showStored, 400));
    slot.addEventListener("mv-discard", showStored);
    slot.addEventListener("submit", (e) => {
      e.preventDefault();
      const btn = host.querySelector("#bs-submit");
      btn.setAttribute("aria-busy", "true");
      setTimeout(() => {
        host.clear();
        showStored();
        const done = document.createElement("div");
        done.className = "bs-done";
        const badge = Object.assign(document.createElement("span"), { className: "mv-badge", textContent: "Submitted" });
        badge.dataset.variant = "success";
        const title = Object.assign(document.createElement("strong"), { textContent: "Application sent" });
        const text = Object.assign(document.createElement("p"), { textContent: "The saved draft was wiped. Simulate a crash now: nothing comes back." });
        done.append(badge, title, text);
        host.querySelector("form").replaceWith(done);
      }, 700);
    });

    function mount({ seed }) {
      slot.replaceChildren(tpl.content.cloneNode(true));
      host = slot.querySelector("mv-bloodstain");
      savedAt = 0;
      if (seed) requestAnimationFrame(() => seedValues());
      showStored();
    }

    // Pre-fill the form as if someone had been typing (input events → autosave).
    function seedValues() {
      const set = (sel, value) => {
        const el = host.querySelector(sel);
        if (el.type === "radio" || el.type === "checkbox") el.checked = true; else el.value = value;
        el.dispatchEvent(new Event("input", { bubbles: true }));
      };
      set("#bs-name", "Amara Okafor");
      set("#bs-email", "[email protected]");
      set("#bs-phone", "+1 415 555 0142");
      set("#bs-site", "https://amaraokafor.design");
      set("#bs-notice", "1m");
      set('[name="arrangement"][value="remote"]');
      set("#bs-pass", "correct-horse-battery");
      set("#bs-updates");
      // Last: the cover note, interrupted mid-sentence (the caret stays at the end).
      set("#bs-letter", "Hi Maya,\n\nI’ve spent six years designing B2B tools, most recently leading the billing redesign at a fintech startup. What draws me to this role is");
    }

    document.getElementById("bs-crash").addEventListener("click", () => {
      if (!host) return;
      host.remove(); // pending edits are flushed on disconnect, like a real tab closing
      const loading = document.createElement("div");
      loading.className = "bs-loading";
      loading.textContent = "Reloading the page…";
      slot.replaceChildren(loading);
      setTimeout(() => mount({ seed: false }), 650);
    });

    // A real reload also works: an existing draft is offered instead of seeding.
    mount({ seed: !readDraft() });
    clearInterval(ticker);
    ticker = setInterval(() => { if (!root.isConnected) clearInterval(ticker); else tick(); }, 5000);
  </script>
</div>

Cultural reference

Dark Souls — FromSoftware (Hidetaka Miyazaki) (2011, video game). When you die, everything you carried drops as a bloodstain exactly where you fell; touch it to recover it all, but die again before reaching it and it is gone for good. In the UI, a form draft is marked on the exact field where the session died and restored with one touch, and editing without recovering replaces it for good.

API

Attributes

NameTypeDefaultDescription
keystringmv-bloodstain:<pathname>:<form id | name | action>Storage key of the draft. Set it explicitly (per record, e.g. "ticket-4821") whenever the same form appears on several URLs or several times on a page.
storagelocal | sessionlocallocalStorage (survives closing the browser) or sessionStorage (this tab only, gone when it closes).
debouncenumber (ms)400Delay after the last edit before the draft is written. Pending edits are always flushed when the tab is hidden, on pagehide and when the element is removed.
ttltime ("24h", "30m", "7d", ms number)24hAge after which a draft is dropped instead of being offered.
forfeitfield | any | neverfieldWhen an unrecovered draft is lost for good: field = on the first edit of a field that has a saved value; any = on the first edit anywhere in the form; never = only via Discard, submit, clear() or the TTL. The banner states the rule (“Editing a saved field will discard it.”). While a draft awaits recovery, autosave is paused so it is never overwritten by accident.
data-bloodstain-ignoreboolean (on a field or any ancestor)Excludes fields from the draft, in addition to the built-in exclusions: type password / hidden / file / submit / button / reset / image, and autocomplete tokens cc-*, one-time-code, new-password, current-password. Fields without name or id and disabled fields are skipped too.

Properties

NameTypeDescription
stain{ t, reason, fields, last, keys } | nullThe draft awaiting recovery (read-only): save time, reason ("pagehide", your died() reason, or null), saved values by field name, last position { name, index, start, end } and the names of the fields that differ from the screen.
dirtybooleanTrue when the current values differ from the ones the form started with (read-only).
stringsPartial<Record<string, string>>Overrides for visible texts and announcements (title, meta {count} {fields} {ago}, field, fields, hintField, hintAny, hintNever, recover, discard, marker {ago}, describe {ago}, announceFound, announceRecovered, announceDiscarded, announceForfeit). English defaults.

Methods

NameDescription
recover()Restores the draft: writes the values, dispatches bubbling input and change on each changed control, flashes the restored fields, focuses the field where the user left off and restores the caret/selection. Emits mv-recover. Returns false when there is nothing to recover.
discard()Drops the draft without restoring it. Emits mv-discard.
clear()Wipes the saved draft and takes the current values as the new baseline. Call it after a successful fetch-based submit (a native submit that nobody prevented does it automatically).
died(reason)Tells the component the session just died (failed request, error boundary…): flushes the draft now, tagged with reason. Called automatically on pagehide when there are unsaved edits. Emits mv-died; returns true if something was saved.
save()Writes the draft immediately, skipping the debounce.

Events

NameDescription
mv-saveThe draft was written. detail: { count, t, reason } (count = fields that differ from the initial values). Handy for a “Draft saved” indicator.
mv-stainA draft that differs from the screen was found on mount; the banner and mark are shown. detail: { count, fields, t, reason }.
mv-dieddied() was called (or the page was hidden for good with unsaved edits). detail: { reason, saved, count }.
mv-recoverThe draft was restored. detail: { fields, count, element, reason } (element = the field that received focus).
mv-forfeitCancelable. The user edited the form without recovering and the old draft is about to be replaced. detail: { fields, reason, field }. preventDefault() keeps the draft.
mv-discardThe user chose Discard. detail: { fields, reason }.

Content structure

NameDescription
(children)A <form> or any region containing inputs, textareas and selects. Controls are identified by name (or id); radio groups, checkbox groups, multiple selects and repeated names are supported.

CSS classes

NameDescription
mv-bloodstain-bannerRecovery banner inserted at the top of the element: -icon, -copy (-title, -sub with -meta and -hint), -actions (Discard, Recover).
mv-bloodstain-markMark pinned to the top inline-end corner of the field where the user left off (host-relative, scrolls with the form): -drop (teardrop with pulse ring) and -mark-label. Clicking it recovers.
[data-bloodstain-restored]Set for 1.6 s on each restored control: fading outline (the focus ring stays visible).

CSS variables

NameDefaultDescription
--mv-bloodstain-colorvar(--mv-accent)Color of the mark, the banner tint and the restored-field outline.

Accessibility

Nothing is ever restored behind the user’s back. When a draft is found, a polite live region announces it once (“Unsaved progress from 12 minutes ago found: 7 fields. Recover or discard it from the banner at the top of the form.”). The banner is a labelled region placed before the form in the DOM, with two real <button>s (Discard, Recover) reachable with Tab. The visual mark is aria-hidden; instead, the field where the user left off gets an aria-describedby pointing to a hidden description (“You left off here 12 minutes ago…”) so screen reader users hear it when they reach that field, and the reference is removed once the draft is resolved. Recover moves focus to that field, restores the caret or selection and announces “Progress recovered: 7 fields restored.”; Discard announces and moves focus to the first field; a forfeit is announced too. The rule for losing the draft is written in the banner, not only implied. Restored values dispatch real input/change events so framework state and validation stay in sync. Reduced motion (OS or data-motion="reduce"): no pulse, no landing or banner animation; the mark keeps a static halo and restored fields a static outline for 1.6 s. The mark is also clickable with a pointer or touch (a shortcut; the banner stays the accessible path).