UI Components Medium
Form Draft Autosave
Persist a local draft with a saved timestamp and restore action for interruption-safe forms.
Open in Lab
MCP
html css javascript react
Targets: TS JS HTML React
Code
:root {
--bg: #0b1020;
--panel: #121a2d;
--line: #263555;
--ink: #eef2ff;
--muted: #9eb1d4;
--accent: #78a9ff;
--ok: #46d39a;
--warn: #f5b955;
}
*, *::before, *::after { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
background: radial-gradient(1100px 560px at 20% -10%, #18233b, var(--bg));
color: var(--ink);
font: 15px/1.5 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
}
button, input, textarea, select { font: inherit; }
.demo {
min-height: 100vh;
display: grid;
place-items: center;
padding: clamp(1rem, 4vw, 3rem);
}
.card {
width: min(560px, 100%);
background: var(--panel);
border: 1px solid var(--line);
border-radius: 20px;
padding: clamp(1.1rem, 3vw, 1.75rem);
box-shadow: 0 24px 70px #0006;
}
.card__head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1.25rem;
}
h1 { margin: 0; font-size: 1.15rem; letter-spacing: -0.01em; }
.sub { margin: 0.25rem 0 0; color: var(--muted); font-size: 0.85rem; }
.badge {
flex: none;
font-size: 0.72rem;
font-weight: 600;
padding: 5px 10px;
border-radius: 999px;
border: 1px solid var(--line);
color: var(--muted);
display: inline-flex;
align-items: center;
gap: 6px;
transition: color 0.2s, border-color 0.2s;
}
.badge::before {
content: "";
width: 7px;
height: 7px;
border-radius: 50%;
background: currentColor;
}
.badge[data-state="typing"] { color: var(--warn); border-color: #4a3c1c; }
.badge[data-state="saving"] { color: var(--accent); border-color: #2c4676; }
.badge[data-state="saving"]::before { animation: pulse 0.9s ease-in-out infinite; }
.badge[data-state="saved"] { color: var(--ok); border-color: #1e4a38; }
@keyframes pulse { 50% { opacity: 0.2; } }
.restore {
border: 1px solid #2c4676;
background: #78a9ff14;
border-radius: 14px;
padding: 0.8rem 0.9rem;
margin-bottom: 1.1rem;
}
.restore p { margin: 0 0 0.65rem; font-size: 0.88rem; }
.restore__actions { display: flex; gap: 0.5rem; }
.field { display: block; margin-bottom: 0.9rem; }
.field > span {
display: block;
font-size: 0.72rem;
font-weight: 700;
color: var(--muted);
margin-bottom: 0.35rem;
text-transform: uppercase;
letter-spacing: 0.06em;
}
input[type="text"], input[type="email"], select, textarea {
width: 100%;
background: #0d1424;
border: 1px solid var(--line);
border-radius: 11px;
color: var(--ink);
padding: 0.62rem 0.75rem;
resize: vertical;
transition: border-color 0.15s, box-shadow 0.15s;
}
input:focus, select:focus, textarea:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px #78a9ff2e;
}
.check {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.88rem;
color: var(--muted);
margin: 0.25rem 0 1.1rem;
}
.check input { accent-color: var(--accent); width: 16px; height: 16px; }
.actions {
display: flex;
align-items: center;
gap: 0.6rem;
flex-wrap: wrap;
border-top: 1px solid var(--line);
padding-top: 1rem;
}
.btn {
background: var(--accent);
color: #0b1020;
border: 1px solid transparent;
border-radius: 11px;
padding: 0.55rem 1rem;
font-weight: 600;
cursor: pointer;
transition: filter 0.15s, transform 0.1s;
}
.btn:hover { filter: brightness(1.1); }
.btn:active { transform: translateY(1px); }
.btn:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; }
.btn--ghost { background: transparent; color: var(--ink); border-color: var(--line); }
.btn--sm { padding: 0.35rem 0.75rem; font-size: 0.82rem; }
.saved {
margin-left: auto;
font-size: 0.78rem;
color: var(--muted);
font-variant-numeric: tabular-nums;
}
.toast {
position: fixed;
left: 50%;
bottom: 24px;
transform: translate(-50%, 12px);
background: #101a30;
border: 1px solid var(--line);
padding: 0.6rem 1rem;
border-radius: 11px;
font-size: 0.85rem;
opacity: 0;
pointer-events: none;
transition: opacity 0.2s, transform 0.2s;
}
.toast.is-on { opacity: 1; transform: translate(-50%, 0); }
@media (prefers-reduced-motion: reduce) {
*, *::before { animation: none !important; transition: none !important; }
}/* Form Draft Autosave
* Debounced serialization of a whole form into localStorage, with a
* "restore vs discard" prompt on load, relative-time stamp, cross-tab sync
* and a flush on pagehide so nothing is lost when the tab dies.
*/
(() => {
const KEY = "stealthis:form-draft-autosave";
const DEBOUNCE = 700;
const form = document.getElementById("form");
const statusEl = document.getElementById("status");
const savedAt = document.getElementById("saved-at");
const restore = document.getElementById("restore");
const restoreAge = document.getElementById("restore-age");
const toast = document.getElementById("toast");
let timer = null;
let lastSaved = null;
let dirty = false;
/* ---------- storage helpers ---------- */
const readStore = () => {
try {
const raw = localStorage.getItem(KEY);
if (!raw) return null;
const parsed = JSON.parse(raw);
return parsed && parsed.values ? parsed : null;
} catch {
return null;
}
};
const serialize = () => {
const values = {};
for (const el of form.elements) {
if (!el.name) continue;
values[el.name] = el.type === "checkbox" ? el.checked : el.value;
}
return values;
};
const apply = (values) => {
for (const el of form.elements) {
if (!el.name || !(el.name in values)) continue;
if (el.type === "checkbox") el.checked = Boolean(values[el.name]);
else el.value = values[el.name];
}
};
const isEmpty = (values) =>
Object.entries(values).every(([, v]) =>
typeof v === "boolean" ? v === false : String(v).trim() === "",
);
/* ---------- relative time ---------- */
const relative = (ts) => {
const secs = Math.round((Date.now() - ts) / 1000);
if (secs < 5) return "just now";
if (secs < 60) return `${secs}s ago`;
const mins = Math.round(secs / 60);
if (mins < 60) return `${mins} min ago`;
const hrs = Math.round(mins / 60);
if (hrs < 24) return `${hrs} h ago`;
return new Date(ts).toLocaleDateString();
};
const setStatus = (state, label) => {
statusEl.dataset.state = state;
statusEl.textContent = label;
};
const renderStamp = () => {
savedAt.textContent = lastSaved
? `Draft saved ${relative(lastSaved)}`
: "No draft saved yet";
};
const showToast = (msg) => {
toast.textContent = msg;
toast.classList.add("is-on");
clearTimeout(showToast._t);
showToast._t = setTimeout(() => toast.classList.remove("is-on"), 2200);
};
/* ---------- save ---------- */
const save = () => {
const values = serialize();
if (isEmpty(values)) {
localStorage.removeItem(KEY);
lastSaved = null;
dirty = false;
setStatus("idle", "Idle");
renderStamp();
return;
}
setStatus("saving", "Saving…");
// Simulated async write boundary — swap for a fetch() to your API.
setTimeout(() => {
lastSaved = Date.now();
try {
localStorage.setItem(KEY, JSON.stringify({ values, savedAt: lastSaved }));
} catch {
setStatus("idle", "Storage full");
return;
}
dirty = false;
setStatus("saved", "Saved");
renderStamp();
}, 250);
};
const scheduleSave = () => {
dirty = true;
setStatus("typing", "Unsaved…");
clearTimeout(timer);
timer = setTimeout(save, DEBOUNCE);
};
/* ---------- wiring ---------- */
form.addEventListener("input", scheduleSave);
form.addEventListener("change", scheduleSave);
// Flush immediately if the tab is being hidden or closed.
const flush = () => {
if (!dirty) return;
clearTimeout(timer);
const values = serialize();
if (isEmpty(values)) return;
localStorage.setItem(KEY, JSON.stringify({ values, savedAt: Date.now() }));
dirty = false;
};
window.addEventListener("pagehide", flush);
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "hidden") flush();
});
// Keep other tabs in sync.
window.addEventListener("storage", (e) => {
if (e.key !== KEY || dirty) return;
const store = readStore();
if (!store) return;
apply(store.values);
lastSaved = store.savedAt;
setStatus("saved", "Synced");
renderStamp();
});
document.getElementById("clear").addEventListener("click", () => {
clearTimeout(timer);
localStorage.removeItem(KEY);
form.reset();
lastSaved = null;
dirty = false;
restore.hidden = true;
setStatus("idle", "Idle");
renderStamp();
showToast("Draft cleared");
});
form.addEventListener("submit", (e) => {
e.preventDefault();
clearTimeout(timer);
localStorage.removeItem(KEY);
form.reset();
lastSaved = null;
dirty = false;
setStatus("idle", "Submitted");
renderStamp();
showToast("Ticket submitted — draft discarded");
});
/* ---------- restore prompt on load ---------- */
const existing = readStore();
if (existing && !isEmpty(existing.values)) {
restoreAge.textContent = relative(existing.savedAt);
restore.hidden = false;
document.getElementById("restore-yes").addEventListener("click", () => {
apply(existing.values);
lastSaved = existing.savedAt;
restore.hidden = true;
setStatus("saved", "Restored");
renderStamp();
showToast("Draft restored");
form.elements.subject.focus();
});
document.getElementById("restore-no").addEventListener("click", () => {
localStorage.removeItem(KEY);
restore.hidden = true;
lastSaved = null;
renderStamp();
showToast("Draft discarded");
});
}
renderStamp();
// Keep the relative timestamp honest without re-saving.
setInterval(renderStamp, 15000);
})();<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Form Draft Autosave</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<main class="demo" data-demo="Form Draft Autosave">
<section class="card">
<header class="card__head">
<div>
<h1>New support ticket</h1>
<p class="sub">Your draft is saved locally as you type.</p>
</div>
<span class="badge" id="status" data-state="idle" role="status" aria-live="polite">Idle</span>
</header>
<div class="restore" id="restore" hidden>
<p>We found an unsent draft from <strong id="restore-age">a moment ago</strong>.</p>
<div class="restore__actions">
<button type="button" class="btn btn--sm" id="restore-yes">Restore draft</button>
<button type="button" class="btn btn--ghost btn--sm" id="restore-no">Discard</button>
</div>
</div>
<form id="form" novalidate>
<label class="field">
<span>Subject</span>
<input name="subject" type="text" autocomplete="off" placeholder="Payment failed on checkout" />
</label>
<label class="field">
<span>Email</span>
<input name="email" type="email" inputmode="email" autocomplete="email" placeholder="[email protected]" />
</label>
<label class="field">
<span>Priority</span>
<select name="priority">
<option value="low">Low</option>
<option value="normal" selected>Normal</option>
<option value="high">High</option>
</select>
</label>
<label class="field">
<span>Details</span>
<textarea name="details" rows="5" placeholder="What happened?"></textarea>
</label>
<label class="check">
<input type="checkbox" name="notify" />
<span>Email me updates on this ticket</span>
</label>
<footer class="actions">
<button type="submit" class="btn">Submit ticket</button>
<button type="button" class="btn btn--ghost" id="clear">Clear draft</button>
<span class="saved" id="saved-at">No draft saved yet</span>
</footer>
</form>
</section>
<div class="toast" id="toast" role="status" aria-live="polite"></div>
</main>
<script src="script.js"></script>
</body>
</html>import { useState } from "react";
export function FormDraftAutosave() {
const [value, setValue] = useState("");
return (
<form className="demo" onSubmit={(event) => event.preventDefault()}>
<h2>Form Draft Autosave</h2>
<label>
{value.length ? "Ready" : "Enter a value"}
<input value={value} onChange={(event) => setValue(event.target.value)} />
</label>
<button type="submit">Continue</button>
</form>
);
}Form Draft Autosave
Persist a local draft with a saved timestamp and restore action for interruption-safe forms.
Support notes
Validation is local and progressively enhanced. Keep labels, live messages, and inputmode attributes intact when adapting these patterns to a real backend.
Included demo
- Vanilla HTML, CSS, and JavaScript with zero external dependencies.
- React equivalent using the same interaction model.
- A Lab route for trying the behavior in isolation.
Integration checklist
Keep state updates separate from presentation, preserve semantic labels, and add persistence or server callbacks at the boundary where your product needs them.