Exclusivebeta

Rewind — <mv-rewind>

A time machine for forms: wraps any region, records every change (grouped typing, checkboxes, selects, contenteditable) as a labeled undo tree (“Address changed”, “Shipping method: Express”), shown as a compact timeline. Dragging along the timeline rewinds the fields live, with a ghost halo on whatever differs from now and the current value as a tag; “Restore this state” commits, and abandoned versions stay reachable as branches. Ctrl/⌘ Z and Shift Z, optional session persistence.

CategoryForms
TypeWeb Component (<mv-rewind>)
Statusbeta
Keywordsexclusive, form, undo, redo, history, time-travel, undo-tree, branching, scrubber, snapshot, restore, checkout, draft

When to use

Avoid when

Install

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

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

Usage

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

<div id="rw-demo" style="width:min(100%,44rem);margin-inline:auto">
  <style>
    #rw-demo .rw-title { display:flex; align-items:baseline; justify-content:space-between; gap:1rem; margin:0 0 .25rem }
    #rw-demo .rw-title h3 { margin:0; font-size:1.125rem; letter-spacing:-.01em }
    #rw-demo .rw-title span { color:var(--mv-fg-muted); font-size:.8125rem }
    #rw-demo form { display:grid; gap:1rem; padding:1.125rem 1.25rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-surface) }
    #rw-demo .rw-grid { display:grid; grid-template-columns:repeat(6,1fr); gap:.75rem 1rem }
    #rw-demo .rw-grid > * { grid-column:span 6 }
    @media (min-width:34rem) {
      #rw-demo .rw-grid > .c3 { grid-column:span 3 } #rw-demo .rw-grid > .c2 { grid-column:span 2 } #rw-demo .rw-grid > .c4 { grid-column:span 4 }
    }
    #rw-demo fieldset { margin:0; padding:0; border:0; min-width:0 }
    #rw-demo legend { padding:0; margin-bottom:.6rem; font-size:.75rem; font-weight:600; letter-spacing:.06em; text-transform:uppercase; color:var(--mv-fg-muted) }
    #rw-demo .rw-ship { display:grid; grid-template-columns:repeat(auto-fit,minmax(11rem,1fr)); gap:.5rem }
    #rw-demo .rw-foot { display:flex; align-items:center; justify-content:space-between; gap:1rem; flex-wrap:wrap; padding-top:.25rem }
    #rw-demo .rw-foot strong { font-size:1.125rem; font-variant-numeric:tabular-nums }
    #rw-demo .rw-hint { margin:.6rem 0 0; color:var(--mv-fg-muted); font-size:.75rem; text-align:center }
  </style>

  <div class="rw-title"><h3>Complete your order</h3><span>Nomad Studio · 2 items</span></div>

  <mv-rewind id="rw-checkout" label="Rewind" placement="top">
    <form onsubmit="event.preventDefault()" aria-label="Order">
      <fieldset>
        <legend>Shipping</legend>
        <div class="rw-grid">
          <div class="mv-field c3"><label class="mv-label" for="rw-first">First name</label><input class="mv-input" id="rw-first" name="first_name" autocomplete="given-name"></div>
          <div class="mv-field c3"><label class="mv-label" for="rw-last">Last name</label><input class="mv-input" id="rw-last" name="last_name" autocomplete="family-name"></div>
          <div class="mv-field"><label class="mv-label" for="rw-street">Address</label><input class="mv-input" id="rw-street" name="address" autocomplete="street-address" data-rewind-label="Address changed"></div>
          <div class="mv-field c2"><label class="mv-label" for="rw-zip">ZIP code</label><input class="mv-input" id="rw-zip" name="zip" inputmode="numeric" autocomplete="postal-code"></div>
          <div class="mv-field c4"><label class="mv-label" for="rw-city">City</label><input class="mv-input" id="rw-city" name="city" autocomplete="address-level2"></div>
        </div>
      </fieldset>

      <fieldset>
        <legend>Shipping method</legend>
        <div class="rw-ship">
          <label class="mv-choice-card"><input type="radio" class="mv-radio" name="shipping" value="standard"><span class="mv-choice-text"><span class="mv-choice-title">Standard</span><span class="mv-choice-description">3–5 days · free</span></span></label>
          <label class="mv-choice-card"><input type="radio" class="mv-radio" name="shipping" value="express"><span class="mv-choice-text"><span class="mv-choice-title">Express</span><span class="mv-choice-description">Tomorrow by 1 p.m. · $9.90</span></span></label>
          <label class="mv-choice-card"><input type="radio" class="mv-radio" name="shipping" value="pickup"><span class="mv-choice-text"><span class="mv-choice-title">Pickup point</span><span class="mv-choice-description">48 hours · $3.90</span></span></label>
        </div>
      </fieldset>

      <div class="rw-grid">
        <label class="mv-choice"><input type="checkbox" class="mv-checkbox" name="gift"><span class="mv-choice-text"><span class="mv-choice-title">Gift wrap</span><span class="mv-choice-description">Recycled paper and a handwritten card · $4</span></span></label>
        <div class="mv-field"><label class="mv-label" for="rw-msg">Card message</label><textarea class="mv-textarea" id="rw-msg" name="message" rows="2"></textarea></div>
      </div>

      <div class="rw-foot">
        <span>Total <strong id="rw-total">$129.90</strong></span>
        <button class="mv-button" type="submit">Pay now</button>
      </div>
    </form>
  </mv-rewind>
  <p class="rw-hint">Drag along the timeline to see the form as it was · <kbd class="mv-kbd">Ctrl</kbd> <kbd class="mv-kbd">Z</kbd> undoes, <kbd class="mv-kbd">Ctrl</kbd> <kbd class="mv-kbd">Shift</kbd> <kbd class="mv-kbd">Z</kbd> redoes</p>

  <script type="module">
    const rw = document.getElementById("rw-checkout");
    await customElements.whenDefined("mv-rewind");
    const f = rw.querySelector("form");
    const set = (name, v) => {
      const els = f.elements[name];
      if (els instanceof RadioNodeList) for (const r of els) r.checked = r.value === v;
      else if (els.type === "checkbox") els.checked = v;
      else els.value = v;
    };
    const min = (m) => Date.now() - m * 60000;
    const total = () => {
      const ship = { standard: 0, express: 9.9, pickup: 3.9 }[f.elements.shipping.value] ?? 0;
      const t = 129.9 + ship + (f.elements.gift.checked ? 4 : 0);
      document.getElementById("rw-total").textContent = t.toLocaleString("en-US", { style: "currency", currency: "USD" });
    };
    f.addEventListener("input", total);
    f.addEventListener("change", total);

    // A realistic past session, so the timeline has a story on first sight.
    [["first_name", "Emma"], ["last_name", "Nakamura"], ["address", "12 Maple Street"], ["zip", "11201"], ["city", "Brooklyn"], ["shipping", "standard"]].forEach(([n, v]) => set(n, v));
    rw.clear({ time: min(19) });
    const step = (name, v, m) => { set(name, v); rw.snapshot({ time: min(m) }); };
    step("shipping", "express", 17);
    step("address", "48 Atlantic Avenue", 15);
    step("zip", "11217", 14);
    step("gift", true, 9);
    step("message", "Happy birthday, Paul! Love, Emma", 8);
    step("shipping", "pickup", 5);
    step("address", "5 Court Street", 4);
    rw.undo(); rw.undo();                       // changed her mind…
    step("shipping", "standard", 1);            // …a new branch starts here
    total();

    rw.addEventListener("mv-restore", (e) => console.info("mv-restore", e.detail.reason, e.detail.label));
  </script>
</div>

API

Attributes

NameTypeDefaultDescription
labelstringHistoryTitle shown in the bar.
placementbottom | topbottomPosition of the bar relative to the wrapped content.
debouncenumber (ms)700Quiet time before typing is recorded (checkboxes, radio buttons and selects are recorded immediately; leaving a field forces a record).
mergenumber (ms)4000Successive edits to the same field within this window form a single entry; going back to the previous value removes the entry.
maxnumber120Maximum number of entries kept (the oldest are pruned).
keyboardregion | document | offregionCtrl/⌘ Z undoes, Ctrl/⌘ Shift Z or Ctrl Y redoes: when focus is inside the region, anywhere on the page (except other fields), or never. Inside the region, the component’s history replaces native per-field undo.
persiststringKeeps the history in sessionStorage under this key (empty value: element id or page path) and replays it on reload.
initial-labelstringInitial stateLabel of the first entry.
data-rewind-labelstringOn a field or a group (fieldset…): fixed entry label, “{value}” is replaced with the new value. Otherwise: “Label: “value””.
data-rewind-namestringOn a field: short name used in automatic labels.
data-rewind-ignorebooleanOn a field or a container: never recorded.

Properties

NameTypeDescription
history{ label, time, current }[]Entries on the displayed line (read-only).
previewingbooleanTrue while previewing the past.

Methods

NameDescription
undo() / redo()Steps back / forward one entry (values written with input and change events).
preview(index)Previews entry number index (0 = the oldest), without committing anything.
restore(index?)Commits the current preview (or entry index).
cancel()Leaves the preview and returns to the present.
snapshot({ label?, time? })Records an entry now (time lets you rebuild an existing history).
clear({ label?, time? })Forgets the history and starts again from the current state.

Events

NameDescription
mv-snapshotNew entry. detail: { label, count }.
mv-previewAn entry is previewed. detail: { label, time, differences: string[] }.
mv-restoreA past state became the current state. detail: { reason: "restore" | "undo" | "redo", label, time, state }.

Content structure

NameDescription
(content)Any region: form, fieldset, contenteditable area. Fields added later are picked up.

CSS classes

NameDescription
mv-rewind-barGenerated bar: title, timeline, undo / redo, preview tray.
mv-rewind-track / -tick / -thumb / -branchTimeline (role slider), entry ticks, thumb and branches for abandoned versions.
mv-rewind-halo / -nowGhost halo set on a field that differs from the present, and its “Current” tag.

CSS variables

NameDefaultDescription
--mv-rewind-accentvar(--mv-accent)Color of the present (fill, thumb).
--mv-rewind-pastvar(--mv-warning)Color of the past: halos, preview, the portion that would be undone.
--mv-rewind-bgBar background.

Accessibility

The timeline is a role="slider": arrows, Home/End and Page Up/Down browse the history in preview, Enter restores, Escape returns to the present; aria-valuetext gives the label and age (“Address changed, 15 minutes ago”). During a preview, the region is inert (nothing can be changed by mistake) and a live region announces how many fields differ; restore, undo and redo are announced too. Branches are real, labeled buttons. Restored values go through native setters and fire input/change (React, Vue… stay in sync). Passwords, credit card fields (autocomplete cc-*), one-time codes and files are never recorded. Reduced motion: no thumb glide or field shake. No loops: everything is event-driven.