← Feedback

Autosave Indicator

A small pill reads “Saving” with three bouncing dots while work is being saved, then rolls over to a freshly drawn tick and “Saved”, easing to the width of each message. Point it at a text box and it follows the typing by itself: saving while you type, saved once you pause. Try typing in the note.

Trip ideas

How to use it

  1. Paste the snippet just before </head>.
  2. Add an empty <span data-autosave-indicator></span> where the status should show, such as beside a document’s title.
  3. Give it data-watch with a selector for the fields it should follow, or call animacious.autosave(el, "saving") and (el, "saved") around your own saves.

Options

Add any of these attributes to the same element.

data-watchCSS selector for the fields to follow, such as #note or form textarea.
data-waitQuiet time after typing before it says “Saved”. Default 900 ms.
data-saving-textDefault “Saving”.
data-saved-textDefault “Saved”.
data-colorColour of the tick. Default #16a34a.
data-autoplaySave by itself every few seconds while in view, for demos. With data-watch and data-autotype it types that text into the field instead, and stops as soon as someone clicks the field.

It’s a polite live region, so screen readers hear “Saving” and “Saved”. It only changes how the save looks: do the saving itself in your own code. With reduced motion the messages swap without moving.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Autosave Indicator: a small pill that reads "Saving" with three bouncing
    // dots while work is being saved, then slides over to a tick and "Saved".
    // The pill eases to the width of each message. Point it at a form field
    // and it follows the typing by itself.
    // Usage: <span data-autosave-indicator></span>
    // Optional: data-watch (a CSS selector: typing in those fields shows
    // "Saving", and "Saved" once typing pauses), data-wait (ms of quiet before
    // "Saved", default 900), data-saving-text (default "Saving"),
    // data-saved-text (default "Saved"), data-color (tick colour, default
    // #16a34a), data-autoplay (saves by itself every few seconds while in
    // view; with data-watch and data-autotype it types that text into the
    // field instead, a few letters at a time, and stops when someone clicks
    // the field).
    // Script use: animacious.autosave(el, 'saving') or (el, 'saved').
    var DEFAULTS = { wait: 900, savingText: 'Saving', savedText: 'Saved', color: '#16a34a' };
    var MIN_SAVING = 700;

    var css =
      '.asi{position:relative;display:inline-flex;align-items:center;justify-content:center;height:2em;padding:0 .85em;border-radius:99px;' +
      'background:rgba(120,120,140,.12);font-size:.9em;font-weight:600;line-height:1;white-space:nowrap;overflow:hidden;vertical-align:middle;box-sizing:border-box}' +
      '.asi-in{display:inline-flex;align-items:center;gap:.4em}' +
      '.asi-out{position:absolute;left:.85em;top:0;bottom:0}' +
      '.asi-tick{width:1.05em;height:1.05em;flex:none;color:var(--asi-c)}' +
      '.asi-dots{display:inline-flex;gap:.2em;margin-left:-.1em;align-self:flex-end;padding-bottom:.62em}' +
      '.asi-dots i{width:.26em;height:.26em;border-radius:50%;background:currentColor;animation:asi-dot 1s ease-in-out infinite}' +
      '.asi-dots i:nth-child(2){animation-delay:.14s}.asi-dots i:nth-child(3){animation-delay:.28s}' +
      '@keyframes asi-dot{0%,55%,100%{transform:translateY(0);opacity:.4}25%{transform:translateY(-.32em);opacity:1}}' +
      '@media (prefers-reduced-motion: reduce){.asi-dots i{animation:none;opacity:.7}}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
    function opt(el, k) { var v = el.dataset[k]; return v === undefined ? DEFAULTS[k] : v; }

    function content(el, state) {
      var span = document.createElement('span');
      span.className = 'asi-in';
      if (state === 'saving') {
        span.innerHTML = '<span></span><span class="asi-dots" aria-hidden="true"><i></i><i></i><i></i></span>';
        span.firstChild.textContent = opt(el, 'savingText');
      } else {
        span.innerHTML = '<svg class="asi-tick" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
          '<path d="M5 12.5l4.5 4.5L19 7.5"/></svg><span></span>';
        span.lastChild.textContent = opt(el, 'savedText');
      }
      return span;
    }

    function show(el, state) {
      var s = el._asi;
      if (s.state === state) return;
      s.state = state;
      if (state === 'saving') s.since = Date.now();
      var next = content(el, state);
      var old = s.cur;
      s.cur = next;
      if (reduced() || !old) {
        el.textContent = '';
        el.appendChild(next);
        return;
      }
      // Ease the pill from the old width to the new one while the messages swap.
      var from = el.offsetWidth;
      old.classList.add('asi-out');
      el.appendChild(next);
      var to = next.offsetWidth + (el.offsetWidth - el.clientWidth) + parseFloat(getComputedStyle(el).paddingLeft) * 2;
      if (s.size) s.size.pause();
      s.size = anime.animate(el, { width: [from, to], duration: 420, ease: 'out(3)', onComplete: function () { el.style.width = ''; } });
      anime.animate(old, { opacity: 0, y: '-.9em', duration: 240, ease: 'in(2)', onComplete: function () { old.remove(); } });
      anime.animate(next, { opacity: [0, 1], y: ['.9em', '0em'], duration: 380, delay: 110, ease: 'out(3)' });
      if (state === 'saved') {
        var tick = anime.svg.createDrawable(next.querySelector('path'));
        anime.utils.set(tick, { draw: '0 0' });
        anime.animate(tick, { draw: ['0 0', '0 1'], duration: 420, delay: 260, ease: 'out(3)' });
        anime.animate(next.querySelector('svg'), { scale: [{ to: 1.3, duration: 160, delay: 560 }, { to: 1, duration: 500, ease: 'outElastic(1, .5)' }] });
      }
    }

    // Saving now; "Saved" once things go quiet (and Saving has shown long enough to read).
    function touch(el) {
      var s = el._asi;
      show(el, 'saving');
      clearTimeout(s.timer);
      s.timer = setTimeout(function () {
        s.timer = setTimeout(function () { show(el, 'saved'); }, Math.max(0, MIN_SAVING - (Date.now() - s.since)));
      }, +opt(el, 'wait'));
    }

    function autoplay(el, fields) {
      var s = el._asi;
      var field = fields[0];
      var text = el.dataset.autotype;
      var typing = field && text && 'value' in field;
      var visible = false, timer = null, pos = 0;
      function stop() { s.auto = false; clearTimeout(timer); }
      function step() {
        if (!s.auto || !visible) return;
        if (typing) {
          // Type a word or two, then pause so it saves.
          if (pos >= text.length) { field.value = ''; pos = 0; touch(el); timer = setTimeout(step, 2600); return; }
          var end = text.indexOf(' ', pos + 4 + Math.floor(Math.random() * 10));
          end = end < 0 ? text.length : end + 1;
          var chunk = text.slice(pos, end);
          pos = end;
          var i = 0;
          (function type() {
            if (!s.auto) return;
            field.value += chunk.charAt(i++);
            field.scrollTop = field.scrollHeight;
            touch(el);
            if (i < chunk.length) timer = setTimeout(type, 45 + Math.random() * 70);
            else timer = setTimeout(step, 2400);
          })();
        } else {
          touch(el);
          timer = setTimeout(step, 3600);
        }
      }
      s.auto = true;
      if (field) {
        if (typing) field.value = '';
        field.addEventListener('pointerdown', stop);
        field.addEventListener('focus', stop);
      }
      new IntersectionObserver(function (entries) {
        var v = entries[0].isIntersecting;
        if (v === visible) return;
        visible = v;
        clearTimeout(timer);
        if (v && s.auto) timer = setTimeout(step, 900);
      }, { threshold: 0.3 }).observe(el);
    }

    function init() {
      document.querySelectorAll('[data-autosave-indicator]').forEach(function (el) {
        if (el._asi) return;
        el._asi = { state: '', cur: null, timer: null, since: 0, auto: false };
        el.classList.add('asi');
        el.style.setProperty('--asi-c', opt(el, 'color'));
        el.setAttribute('role', 'status');
        el.setAttribute('aria-live', 'polite');
        show(el, 'saved');
        var fields = [];
        if (el.dataset.watch) {
          try { fields = Array.prototype.slice.call(document.querySelectorAll(el.dataset.watch)); } catch (err) { fields = []; }
          fields.forEach(function (f) { f.addEventListener('input', function (e) { if (e.isTrusted) touch(el); }); });
        }
        if (el.dataset.autoplay !== undefined && el.dataset.autoplay !== 'false' && !reduced()) autoplay(el, fields);
      });
    }

    window.animacious = window.animacious || {};
    window.animacious.autosave = function (el, state) {
      if (!el._asi) return;
      clearTimeout(el._asi.timer);
      show(el, state === 'saving' ? 'saving' : 'saved');
    };
    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<span data-autosave-indicator data-watch="#note"></span>
<textarea id="note"></textarea>

<!-- or around your own saves: -->
<script>
  animacious.autosave(indicator, "saving");
  await save();
  animacious.autosave(indicator, "saved");
</script>