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
- Paste the snippet just before
</head>. - Add an empty
<span data-autosave-indicator></span>where the status should show, such as beside a document’s title. - Give it
data-watchwith a selector for the fields it should follow, or callanimacious.autosave(el, "saving")and(el, "saved")around your own saves.
Options
Add any of these attributes to the same element.
data-watch | CSS selector for the fields to follow, such as #note or form textarea. |
data-wait | Quiet time after typing before it says “Saved”. Default 900 ms. |
data-saving-text | Default “Saving”. |
data-saved-text | Default “Saved”. |
data-color | Colour of the tick. Default #16a34a. |
data-autoplay | Save 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.
<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><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>