Forms · Complete runnable example

Opt-in local draft autosave

Keep a short draft locally only after the reader chooses to save.

Live example

This preview runs in an isolated document. Clipboard writes and saved preferences may be blocked, and printing requires the standalone file. A dialog affects only the example. Reset reloads the original example state.

When to use it

Use this pattern for short, nonsensitive notes on a permitted origin. The textarea works independently of storage. Opting in checks for an existing draft and asks whether to restore it or replace it with the current text.

Try the important details

  1. Type while autosave is off, then explicitly enable it on a permitted standalone origin.
  2. Reload, opt in and choose Restore saved draft. Use Clear saved draft to remove storage without erasing the current text.
  3. Try the embedded preview, where opaque-origin storage is denied. The status explains the limit and never reports Saved.

How it works

  • A 500 ms debounce writes a versioned object only while opted in. The Saved status appears only after setItem returns successfully.
  • Stored JSON is checked for the expected version, string length and numeric timestamp. Corrupt data is left untouched until an explicit clear.
  • A pending restore choice prevents overwriting the existing draft. Read, write, quota and clear failures keep the editor usable.

The complete source

The preview, copied text, and downloaded file use this same document.

HTML

<!doctype html>
<!--
MIT License
Copyright (c) 2026 MRHTML

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
-->
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <meta
      http-equiv="Content-Security-Policy"
      content="default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; img-src data: blob:; connect-src 'none'; form-action 'none'; object-src 'none'; base-uri 'none'"
    />
    <title>A draft, on this device. | MRHTML example</title>
    <style>
      :root {
        color-scheme: light;
        --paper: #f5f3ee;
        --surface: #fff;
        --ink: #172a3b;
        --muted: #536575;
        --line: #d2dce1;
        --blue: #234ed8;
        --soft: #eef3ff;
        --orange: #ad4b17;
        --error: #a62440;
        font:
          16px/1.6 system-ui,
          -apple-system,
          BlinkMacSystemFont,
          'Segoe UI',
          sans-serif;
        color: var(--ink);
        background: var(--paper);
      }
      * {
        box-sizing: border-box;
      }
      body {
        margin: 0;
        padding: clamp(24px, 6vw, 64px) 22px;
        min-height: 100svh;
      }
      main {
        width: min(760px, 100%);
        margin: auto;
        min-width: 0;
      }
      h1,
      h2,
      h3,
      p {
        margin: 0;
      }
      h1 {
        font-size: clamp(2rem, 6vw, 3rem);
        line-height: 1.08;
        letter-spacing: -0.055em;
        font-weight: 680;
        max-width: 680px;
      }
      h2 {
        font-size: 1.2rem;
        line-height: 1.3;
        letter-spacing: -0.02em;
      }
      p {
        color: var(--muted);
      }
      header {
        margin-bottom: 28px;
      }
      .brand {
        font:
          700 0.73rem/1.5 ui-monospace,
          monospace;
        letter-spacing: 0.1em;
        text-transform: uppercase;
        color: var(--blue);
        margin-bottom: 20px;
      }
      .intro {
        max-width: 62ch;
        margin-top: 16px;
      }
      .surface {
        background: var(--surface);
        border: 1px solid var(--line);
        border-radius: 18px;
        padding: clamp(20px, 4vw, 32px);
      }
      .note {
        font-size: 0.85rem;
        margin-top: 20px;
      }
      .eyebrow {
        font:
          600 0.72rem/1.5 ui-monospace,
          monospace;
        letter-spacing: 0.08em;
        text-transform: uppercase;
      }
      .actions {
        display: flex;
        flex-wrap: wrap;
        gap: 10px;
        margin-top: 20px;
      }
      button,
      input,
      textarea,
      select {
        font: inherit;
      }
      button {
        border: 1px solid var(--ink);
        background: var(--ink);
        color: #fff;
        border-radius: 8px;
        padding: 10px 17px;
        min-height: 44px;
        font-weight: 650;
        cursor: pointer;
      }
      button.secondary {
        background: transparent;
        color: var(--ink);
        border-color: var(--line);
      }
      button:hover:not(:disabled) {
        background: var(--blue);
        border-color: var(--blue);
        color: #fff;
      }
      button:disabled {
        cursor: not-allowed;
        opacity: 0.55;
      }
      label {
        font-weight: 600;
      }
      input:not([type='checkbox']):not([type='range']):not([type='file']),
      textarea,
      select {
        width: 100%;
        min-width: 0;
        border: 1px solid #8e9faa;
        border-radius: 8px;
        padding: 10px 12px;
        background: #fff;
        color: var(--ink);
      }
      textarea {
        resize: vertical;
        min-height: 180px;
      }
      input[type='range'] {
        width: 100%;
        accent-color: var(--blue);
      }
      input[type='checkbox'] {
        width: 20px;
        height: 20px;
        accent-color: var(--blue);
      }
      :focus-visible {
        outline: 3px solid var(--orange);
        outline-offset: 4px;
      }
      a {
        color: var(--blue);
      }
      .status {
        min-height: 1.6em;
        font-size: 0.9rem;
        margin-top: 18px;
        overflow-wrap: anywhere;
      }
      .status[data-error='true'] {
        color: var(--error);
      }
      [hidden] {
        display: none !important;
      }
      .sr-only {
        position: absolute;
        width: 1px;
        height: 1px;
        padding: 0;
        margin: -1px;
        overflow: hidden;
        clip: rect(0, 0, 0, 0);
        white-space: nowrap;
        border: 0;
      }
      .field {
        display: grid;
        gap: 7px;
        margin-top: 16px;
      }
      footer {
        font-size: 0.75rem;
        color: var(--muted);
        border-top: 1px solid var(--line);
        margin-top: 30px;
        padding-top: 16px;
        display: flex;
        justify-content: space-between;
        gap: 12px;
      }
      noscript {
        display: block;
        padding: 14px;
        background: #fff2d9;
        border: 1px solid #ad4b17;
        margin-top: 20px;
        border-radius: 8px;
        color: var(--ink);
      }
      @media (max-width: 400px) {
        body {
          padding: 25px 16px;
        }
        .surface {
          padding: 20px;
        }
        button {
          padding: 10px 14px;
        }
      }
      @media (prefers-reduced-motion: reduce) {
        *,
        *::before,
        *::after {
          scroll-behavior: auto !important;
          animation: none !important;
          transition: none !important;
        }
      }

      .draft-top {
        display: flex;
        justify-content: space-between;
        gap: 15px;
      }
      .draft-top output {
        font:
          500 0.75rem ui-monospace,
          monospace;
        color: var(--muted);
      }
      #draft {
        min-height: 220px;
        line-height: 1.75;
        background: #fbfcff;
      }
      .opt-in {
        display: flex;
        align-items: start;
        gap: 10px;
        padding-top: 22px;
        margin-top: 22px;
        border-top: 1px solid var(--line);
        font-size: 0.9rem;
      }
      .opt-in input {
        flex-shrink: 0;
        margin: 3px 0 0;
      }
      #restore-choice {
        background: var(--soft);
        padding: 18px;
        margin-top: 20px;
        border-radius: 9px;
      }
      #restore-choice p {
        font-size: 0.85rem;
      }
    </style>
  </head>
  <body>
    <main>
      <header>
        <p class="brand">MRHTML / Runnable example</p>
        <h1>A draft, on this device.</h1>
        <p class="intro">
          Write a short note. Saving is off until you explicitly choose to keep
          a local draft.
        </p>
      </header>
      <section class="surface">
        <div class="draft-top">
          <span class="eyebrow">Working note</span
          ><output id="character-count" for="draft">0 / 4000</output>
        </div>
        <div class="field">
          <label class="sr-only" for="draft">Draft text</label
          ><textarea
            id="draft"
            maxlength="4000"
            placeholder="What would you like to come back to?"
            aria-describedby="draft-help"
          ></textarea>
        </div>
        <p id="draft-help" class="note">
          Use this for nonsensitive notes. Local storage belongs to this browser
          and site; it is not a backup or account sync.
        </p>
        <label class="opt-in"
          ><input id="autosave" type="checkbox" disabled /><span
            >Keep a local draft on this device</span
          ></label
        >
        <div id="restore-choice" hidden>
          <p>
            A previous draft is available. Choose which text to keep before
            autosave starts.
          </p>
          <div class="actions">
            <button id="restore-draft" type="button">Restore saved draft</button
            ><button id="use-current" class="secondary" type="button">
              Use current text
            </button>
          </div>
        </div>
        <div class="actions">
          <button id="clear-draft" class="secondary" type="button" disabled>
            Clear saved draft
          </button>
        </div>
        <p id="draft-status" class="status" role="status" aria-atomic="true">
          Autosave is off. Your current text exists only in this page.
        </p>
      </section>
      <p class="note">
        The embedded preview blocks local storage. Editing still works; use a
        standalone copy on a permitted origin to try saving. Turning autosave
        off keeps any previous saved draft until you clear it.
      </p>
      <noscript
        >You can edit this note, but it is not saved. JavaScript is needed for
        the optional local-draft controls.</noscript
      >
      <footer>
        <span>MRHTML example</span><span>Original code · MIT</span>
      </footer>
    </main>
    <script>
      'use strict';
      (() => {
        const key = 'mrhtml-example-local-draft-v1',
          draft = document.querySelector('#draft'),
          opt = document.querySelector('#autosave'),
          status = document.querySelector('#draft-status'),
          choice = document.querySelector('#restore-choice'),
          clear = document.querySelector('#clear-draft');
        let timer = 0,
          pending = null;
        const report = (text, error = false) => {
          status.textContent = text;
          status.dataset.error = String(error);
        };
        const counter = () =>
          (document.querySelector('#character-count').value =
            `${draft.value.length} / 4000`);
        function fail(text) {
          clearTimeout(timer);
          opt.checked = false;
          pending = null;
          choice.hidden = true;
          report(text, true);
        }
        function save() {
          clearTimeout(timer);
          if (!opt.checked || pending) return;
          try {
            localStorage.setItem(
              key,
              JSON.stringify({
                version: 1,
                text: draft.value,
                updatedAt: Date.now(),
              }),
            );
            report('Saved locally on this device.');
          } catch (error) {
            fail(
              error.name === 'QuotaExceededError'
                ? 'Not saved: local storage is full. Autosave is off; your text is still here.'
                : 'Not saved: local storage is unavailable. Autosave is off; your text is still here.',
            );
          }
        }
        opt.disabled = false;
        clear.disabled = false;
        opt.addEventListener('change', () => {
          clearTimeout(timer);
          pending = null;
          choice.hidden = true;
          if (!opt.checked) {
            report('Autosave is off. Any previous saved draft is kept.');
            return;
          }
          let raw;
          try {
            raw = localStorage.getItem(key);
          } catch {
            fail(
              'Local storage is unavailable here. Nothing was saved; your text is still here.',
            );
            return;
          }
          if (raw !== null) {
            try {
              const item = JSON.parse(raw);
              if (
                !item ||
                item.version !== 1 ||
                typeof item.text !== 'string' ||
                item.text.length > 4000 ||
                !Number.isFinite(item.updatedAt)
              )
                throw Error('invalid');
              pending = item;
              choice.hidden = false;
              report(
                'Previous draft found. Choose Restore or Use current text.',
              );
            } catch {
              fail(
                'Stored draft data could not be read. It was left untouched. Clear the saved draft to remove it.',
              );
            }
          } else save();
        });
        draft.addEventListener('input', () => {
          counter();
          clearTimeout(timer);
          if (opt.checked && !pending) {
            report('Changes are not saved yet…');
            timer = setTimeout(save, 500);
          } else if (!opt.checked)
            report('Autosave is off. Your edits exist only in this page.');
        });
        document
          .querySelector('#restore-draft')
          .addEventListener('click', () => {
            if (!pending) return;
            draft.value = pending.text;
            pending = null;
            choice.hidden = true;
            counter();
            report('Restored the local draft. Future edits will autosave.');
            draft.focus();
          });
        document.querySelector('#use-current').addEventListener('click', () => {
          pending = null;
          choice.hidden = true;
          save();
          draft.focus();
        });
        clear.addEventListener('click', () => {
          clearTimeout(timer);
          opt.checked = false;
          pending = null;
          choice.hidden = true;
          try {
            localStorage.removeItem(key);
            report(
              'Saved draft cleared. Autosave is off; current text is unchanged.',
            );
          } catch {
            report(
              'Could not clear local storage. Autosave is off; current text is unchanged.',
              true,
            );
          }
        });
        window.addEventListener('pagehide', () => {
          if (opt.checked && !pending) save();
        });
        counter();
      })();
    </script>
  </body>
</html>