Exclusivebeta
Saved State <mv-saved-state>
Autosave status line (Unsaved changes · Saving… · Saved · 2 minutes ago · Offline · Couldn’t save · Retry) driven by saving() / saved() / failed() or track(promise), or by pointing it at a form. What hand-written ones get wrong: it never flickers (“Saving…” only for saves slower than 400 ms, “Saved” held at least 1 s), never claims “Saved” while offline or while edits typed during the save are still pending, and announces only errors, going offline and coming back.
| Category | Feedback |
|---|---|
| Type | Web Component (<mv-saved-state>) |
| Status | beta |
| Keywords | exclusive, light, autosave, saving, saved, status, offline, unsaved, beforeunload, retry |
When to use
- A document, note or settings page saves on its own and should say whether the latest edits are safe
- A form saves in the background and users need a truthful Offline or Couldn’t save message with a way to retry
- Leaving the page with edits still pending should ask for confirmation
Avoid when
- Changes are queued locally and synced later, with a pending list, conflicts and background retries → use Bench Sync instead
- The form saves only when the user clicks Save and pending edits should grow more insistent → use Tell-Tale instead
- The result of a one-off action should appear briefly and then disappear → use Toast instead
Install
node scripts/add.mjs saved-state --out ./src/marvelousAI agent with the Marvelous UI MCP server: install_components({ slugs: ["saved-state"], 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/saved-state/saved-state.js, components/saved-state/saved-state.css.
Usage
Quick start, the smallest working markup:
<mv-saved-state for="doc-form" guard></mv-saved-state>
<!-- in your autosave code: savedState.track(fetch("/api/doc", { method: "PUT", body })) -->Canonical markup, to start from and customize with attributes, data-* and CSS variables:
<div style="display:grid;gap:1.25rem;width:100%;max-width:560px">
<form id="mv-saved-demo-doc" class="mv-card" style="padding:0;overflow:hidden">
<div style="display:flex;align-items:center;justify-content:space-between;gap:1rem;padding:.7rem 1rem;border-bottom:1px solid var(--mv-border)">
<span style="display:flex;align-items:center;gap:.5rem;min-width:0;font-size:.8rem;color:var(--mv-fg-muted)">
<span>Marketing</span><span aria-hidden="true">/</span><span style="color:var(--mv-fg);font-weight:600">Q4 launch brief</span>
</span>
<mv-saved-state id="mv-saved-demo" for="mv-saved-demo-doc" guard></mv-saved-state>
</div>
<div style="display:grid;gap:.6rem;padding:1rem">
<input class="mv-input" name="title" value="Aurora 2.0 launch, Oct 14" aria-label="Title">
<textarea class="mv-textarea" name="body" rows="4" aria-label="Brief">Launch in the US, Brazil and Japan on Tuesday, October 14. Early-bird pricing at $49/year until October 31. Owners: Priya Raman (press), Kenji Watanabe (store listings), Lucía Ortega (support macros).</textarea>
</div>
</form>
<div style="display:flex;flex-wrap:wrap;gap:.5rem 1.5rem;font-size:.85rem">
<label class="mv-choice"><input type="checkbox" role="switch" class="mv-switch" id="mv-saved-demo-slow"><span class="mv-choice-text">Slow network</span></label>
<label class="mv-choice"><input type="checkbox" role="switch" class="mv-switch" id="mv-saved-demo-fail"><span class="mv-choice-text">Server error</span></label>
<label class="mv-choice"><input type="checkbox" role="switch" class="mv-switch" id="mv-saved-demo-offline"><span class="mv-choice-text">Offline</span></label>
</div>
<p style="margin:-.5rem 0 0;font-size:.8rem;color:var(--mv-fg-subtle)">Type to autosave. Saves faster than 400 ms never flash “Saving…”.</p>
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(15rem,1fr));gap:.6rem 1.5rem;padding-top:1rem;border-top:1px solid var(--mv-border)">
<mv-saved-state id="mv-saved-demo-a"></mv-saved-state>
<mv-saved-state id="mv-saved-demo-b"></mv-saved-state>
<mv-saved-state id="mv-saved-demo-c"></mv-saved-state>
<mv-saved-state id="mv-saved-demo-d"></mv-saved-state>
</div>
</div>
<script type="module">
const form = document.getElementById("mv-saved-demo-doc");
const status = document.getElementById("mv-saved-demo");
const $ = (id) => document.getElementById(`mv-saved-demo-${id}`);
const ago = (min) => new Date(Date.now() - min * 60e3).toISOString();
status.setAttribute("saved-at", ago(2));
// Stand-in for fetch(): fast by default, slow, failing or offline on demand.
const save = () => new Promise((resolve, reject) => setTimeout(() => {
if ($("offline").checked) reject(new TypeError("Failed to fetch"));
else if ($("fail").checked) reject(new Error("The server answered 503 Service Unavailable"));
else resolve();
}, $("slow").checked ? 1800 : 250));
let timer = 0;
form.addEventListener("submit", (e) => e.preventDefault());
form.addEventListener("input", () => {
clearTimeout(timer);
timer = setTimeout(() => status.track(save()).catch(() => {}), 800);
});
status.addEventListener("mv-retry", (e) => e.detail.waitUntil(save()));
$("offline").addEventListener("change", (e) => dispatchEvent(new Event(e.target.checked ? "offline" : "online")));
// One of each state.
$("a").setAttribute("saved-at", ago(180));
customElements.whenDefined("mv-saved-state").then(() => {
$("b").unsaved();
$("c").saving();
$("d").failed(new Error("Your session expired"));
$("d").addEventListener("mv-retry", (e) => e.detail.waitUntil(new Promise((r) => setTimeout(r, 1200))));
});
</script>API
Attributes
| Name | Type | Default | Description |
|---|---|---|---|
for | form id | Form whose input and change events mark the line “Unsaved changes”. Without it, the closest ancestor form, if any. | |
guard | boolean | Asks for confirmation before leaving the page while edits are unsaved, saving or failed. The beforeunload listener exists only during those states. | |
saved-at | ISO date | Time of the last save known when the page loads (server-rendered). Shows “Saved · …” from the start. | |
locale | BCP 47 | en-US | Locale for the relative time (Intl.RelativeTimeFormat) and the full date in the tooltip. |
data-state | idle | unsaved | saving | saved | offline | error | Set by the component: the state currently displayed (for styling). |
Properties
| Name | Type | Description |
|---|---|---|
state | idle | unsaved | saving | saved | error | Read-only. The state reported by the app, before the anti-flicker timing and the offline overlay. |
lastSaved | Date | null | Read-only. Time of the last successful save. |
error | unknown | Read-only. The value passed to failed(), until the next successful save. |
online | boolean | Read-only. Connectivity as seen through navigator.onLine and the online / offline events. |
strings | Partial<Record<string, string>> | Overrides for every visible or announced text: idle (""), unsaved, saving, saved ("Saved · {time}"), justNow, savedTitle ("Last saved {date}"), offline, offlineIdle, backOnline, error, retry. English defaults. |
Methods
| Name | Description |
|---|---|
unsaved() | Edits are pending. During a save, remembers them so the next saved() shows “Unsaved changes” instead of “Saved”. Ignored while an error is shown. |
saving() | A save started. Displayed only if it lasts longer than 400 ms. |
saved(at?) | The save succeeded (at: Date or ISO string, default now). “Saved · just now”, then the time updates quietly. |
failed(error?) | The save failed. Shows “Couldn’t save · Retry”; error.message becomes the tooltip. |
track(promise) | saving(), then saved() or failed(reason) when the promise settles. Only the latest tracked promise counts. Returns the promise. |
reset() | Back to idle, forgetting the last save and error. |
Events
| Name | Description |
|---|---|
mv-retry | Retry was clicked, or the connection came back after a failure. detail = { reason: "click" | "online", error, waitUntil(promise) }. Pass your save to waitUntil (synchronously) to track it. Cancelable: preventDefault() keeps the error on screen. If nobody handles it, the line falls back to “Unsaved changes”. |
CSS classes
| Name | Description |
|---|---|
mv-saved-state-icon / -text / -retry | Generated parts: the icon (aria-hidden), the status text (holds a <time> element when saved) and the Retry button. |
CSS variables
| Name | Default | Description |
|---|---|---|
--mv-saved-state-color | var(--mv-fg-muted) | Text and neutral icon color. |
--mv-saved-state-success | var(--mv-success) | Check icon when saved. |
--mv-saved-state-unsaved | var(--mv-warning) | Dot icon for unsaved changes. |
--mv-saved-state-offline | var(--mv-warning) | Offline icon. |
--mv-saved-state-error | var(--mv-danger) | Text and icon color on failure. |
--mv-saved-state-font-size | var(--mv-text-sm) | Text size. |
--mv-saved-state-icon-size | 1.05em | Icon size. |
Accessibility
Plain text in the page, so it can be read at any time without being chatty: the visible line is not a live region, and the relative time updates silently. A separate polite role=status region announces only the transitions that matter: “Couldn’t save”, going offline (“Offline · will save when you’re back”) and “Back online”; routine Unsaved / Saving / Saved changes are never announced, and the message is cleared after a few seconds so it is not read twice later. The state is carried by the words, never by the icon alone (icons are aria-hidden and use currentColor). Retry is a real <button> with a visible focus ring; if it disappears while focused, focus moves to the status text instead of being lost. The saved time is a <time datetime> with the full date as a tooltip. Reduced motion (OS or data-motion="reduce"): the spinner stops and state changes swap instantly. Forced colors: system text and link colors. The beforeunload guard is attached only while edits are pending, keeping the back-forward cache usable otherwise.