ExklusivBeta
Rewind <mv-rewind>
Eine Zeitmaschine für Formulare: Umhüllt einen beliebigen Bereich und zeichnet jede Änderung (gruppiertes Tippen, Checkboxen, Selects, contenteditable) als beschrifteten Undo-Baum auf („Address changed“, „Shipping method: Express“), dargestellt als kompakte Zeitleiste. Ziehen entlang der Zeitleiste spult die Felder live zurück, mit einem Geisterschein um alles, was vom aktuellen Stand abweicht, und dem aktuellen Wert als Tag; „Restore this state“ übernimmt, und verworfene Versionen bleiben als Zweige erreichbar. Ctrl/⌘ Z und Shift Z, optionale Speicherung pro Sitzung.
| Kategorie | Formulare |
|---|---|
| Typ | Web Component (<mv-rewind>) |
| Status | Beta |
| Kit | Formulare, die niemanden verlieren |
| Keywords | exclusive, form, undo, redo, history, time-travel, undo-tree, branching, scrubber, snapshot, restore, checkout, draft |
When to use
- A long form, checkout or draft editor should let users undo and browse past states safely
- Users need to recover a value they changed earlier without losing their current edits
- Form edits should survive a reload through session persistence
Avoid when
- The form is short enough that native field undo is sufficient
- The need is scrubbing through media time rather than form history → use Timeline Scrubber instead
Installation
node scripts/add.mjs rewind --out ./src/marvelousKI-Agent mit dem MCP-Server von Marvelous UI: install_components({ slugs: ["rewind"], 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/rewind/rewind.js, components/rewind/rewind.css.
Verwendung
Schnellstart mit dem kleinsten funktionierenden Markup:
<mv-rewind>
<form>
<label>Address <input name="address" value="12 Main St"></label>
<label>Shipping <select name="shipping"><option>Standard</option><option>Express</option></select></label>
</form>
</mv-rewind>Referenz-Markup zum Starten und Anpassen mit Attributen, data-* und CSS-Variablen:
<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
| Name | Typ | Default | Description |
|---|---|---|---|
label | string | History | Title shown in the bar. |
placement | bottom | top | bottom | Position of the bar relative to the wrapped content. |
debounce | number (ms) | 700 | Quiet time before typing is recorded (checkboxes, radio buttons and selects are recorded immediately; leaving a field forces a record). |
merge | number (ms) | 4000 | Successive edits to the same field within this window form a single entry; going back to the previous value removes the entry. |
max | number | 120 | Maximum number of entries kept (the oldest are pruned). |
keyboard | region | document | off | region | Ctrl/⌘ 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. |
persist | string | Keeps the history in sessionStorage under this key (empty value: element id or page path) and replays it on reload. | |
initial-label | string | Initial state | Label of the first entry. |
data-rewind-label | string | On a field or a group (fieldset…): fixed entry label, “{value}” is replaced with the new value. Otherwise: “Label: “value””. | |
data-rewind-name | string | On a field: short name used in automatic labels. | |
data-rewind-ignore | boolean | On a field or a container: never recorded. |
Properties
| Name | Typ | Description |
|---|---|---|
history | { label, time, current }[] | Entries on the displayed line (read-only). |
previewing | boolean | True while previewing the past. |
Methods
| Name | Description |
|---|---|
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
| Name | Description |
|---|---|
mv-snapshot | New entry. detail: { label, count }. |
mv-preview | An entry is previewed. detail: { label, time, differences: string[] }. |
mv-restore | A past state became the current state. detail: { reason: "restore" | "undo" | "redo", label, time, state }. |
Content structure
| Name | Description |
|---|---|
(content) | Any region: form, fieldset, contenteditable area. Fields added later are picked up. |
CSS classes
| Name | Description |
|---|---|
mv-rewind-bar | Generated bar: title, timeline, undo / redo, preview tray. |
mv-rewind-track / -tick / -thumb / -branch | Timeline (role slider), entry ticks, thumb and branches for abandoned versions. |
mv-rewind-halo / -now | Ghost halo set on a field that differs from the present, and its “Current” tag. |
CSS variables
| Name | Default | Description |
|---|---|---|
--mv-rewind-accent | var(--mv-accent) | Color of the present (fill, thumb). |
--mv-rewind-past | var(--mv-warning) | Color of the past: halos, preview, the portion that would be undone. |
--mv-rewind-bg | Bar 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.