← Feedback

Sync Status Icon

A cloud’s two arrows spin round while it syncs. When the sync is done they shrink away, a tick draws itself in their place, and the cloud turns green with a little squash and bounce. A short label beside it rolls from “Syncing…” to “All synced”.

How to use it

  1. Paste the snippet just before </head>.
  2. Add an empty <div data-sync-status-icon></div>. Its font-size is the size of the cloud.
  3. Add data-manual and tell it what’s happening: animacious.syncStatus(el, "syncing") when a sync starts and (el, "done") when it finishes. Without it, it syncs for a moment and finishes by itself.

Options

Add any of these attributes to the same element.

data-syncing-textLabel while syncing. Default “Syncing…”.
data-done-textLabel when done. Default “All synced”.
data-label="false"Just the icon, no label.
data-colorColour while syncing. Default #2563eb.
data-done-colorColour when done. Default #16a34a.
data-durationHow long it syncs before the tick, when it runs by itself. Default 2400 ms.
data-manualSpin until you call animacious.syncStatus(el, "done").
data-loopStart syncing again after a pause.
data-holdHow long the tick shows before looping. Default 2200 ms.

It’s a status region, so screen readers hear the label change. The arrows only spin while on screen. With reduced motion it shows the finished state, or swaps straight between states when you drive it.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Sync Status Icon: a cloud whose two arrows spin round while it syncs.
    // When the sync finishes the arrows shrink away, a tick draws itself in
    // their place and the cloud turns green with a little bounce. A short
    // label beside it says what is going on.
    // Usage: <div data-sync-status-icon></div>   (size it with font-size: 1em = the cloud)
    // Optional: data-syncing-text (default "Syncing…"), data-done-text
    // (default "All synced"), data-label="false" (just the icon), data-color
    // (cloud while syncing, default #2563eb), data-done-color (default
    // #16a34a), data-duration (ms it syncs for before the tick, default 2400),
    // data-loop (start syncing again after a pause), data-hold (ms the tick
    // shows before looping, default 2200), data-manual (wait for script calls
    // instead of finishing by itself).
    // Script use: animacious.syncStatus(el, 'syncing') or (el, 'done').
    var DEFAULTS = { syncingText: 'Syncing…', doneText: 'All synced', color: '#2563eb', doneColor: '#16a34a', duration: 2400, hold: 2200 };
    var uid = 0;

    var css =
      '.ssi{display:inline-flex;align-items:center;gap:.16em;line-height:1.2;vertical-align:middle}' +
      '.ssi svg{flex:none;width:1em;height:1em;overflow:visible}' +
      '.ssi-arrows,.ssi-tick{transform-box:view-box;transform-origin:12px 13.6px}' +
      '.ssi-cloud{transform-box:view-box;transform-origin:12px 20px}' +
      '.ssi-label{position:relative;display:inline-grid;font-size:.32em;font-weight:700;white-space:nowrap;overflow:hidden}' +
      '.ssi-label>span{grid-area:1/1}';
    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 setup(el) {
      var id = 'ssi-ah-' + (++uid);
      var c = opt(el, 'color');
      el.classList.add('ssi');
      el.style.color = c;
      el.setAttribute('role', 'status');
      el.innerHTML =
        '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
        '<defs><marker id="' + id + '" viewBox="0 0 6 6" refX="3" refY="3" markerWidth="2.4" markerHeight="2.4" orient="auto">' +
        '<path d="M.5 .5L5 3L.5 5.5z" fill="currentColor" stroke="none"/></marker></defs>' +
        '<g class="ssi-cloud"><path d="M18 9.6h-1.26A8 8 0 1 0 9 19.6h9a5 5 0 0 0 0-10z" stroke-width="1.9"/></g>' +
        '<g class="ssi-arrows" stroke-width="1.5">' +
        '<path d="M8.6 13.6a3.4 3.4 0 0 1 5.5-2.67" marker-end="url(#' + id + ')"/>' +
        '<path d="M15.4 13.6a3.4 3.4 0 0 1-5.5 2.67" marker-end="url(#' + id + ')"/></g>' +
        '<path class="ssi-tick" d="M8.7 13.9l2.3 2.3l4.4-4.6" stroke-width="2"/>' +
        '</svg>' +
        (el.dataset.label === 'false' ? '' : '<span class="ssi-label"><span></span><span></span></span>');
      var s = el._ssi = {
        svg: el.querySelector('svg'), cloud: el.querySelector('.ssi-cloud'),
        arrows: el.querySelector('.ssi-arrows'),
        tick: anime.svg.createDrawable(el.querySelector('.ssi-tick')),
        label: el.querySelector('.ssi-label'), spin: null, timer: null, state: '', visible: false, waiting: false
      };
      if (s.label) {
        s.label.children[0].textContent = opt(el, 'syncingText');
        s.label.children[1].textContent = opt(el, 'doneText');
      }
      if (reduced()) { show(el, el.dataset.manual === undefined ? 'done' : 'syncing', true); return; }
      // Hidden until it scrolls into view and starts.
      anime.utils.set(s.arrows, { scale: 0, opacity: 0 });
      anime.utils.set(s.tick, { draw: '0 0' });
      if (s.label) anime.utils.set(s.label.children, { opacity: 0 });
    }

    function labels(el, done, instant) {
      var s = el._ssi;
      if (!s.label) return;
      var a = s.label.children[done ? 1 : 0], b = s.label.children[done ? 0 : 1];
      a.removeAttribute('aria-hidden');
      b.setAttribute('aria-hidden', 'true');
      if (instant) {
        anime.utils.set(a, { opacity: 1, y: '0em' });
        anime.utils.set(b, { opacity: 0, y: '0em' });
        return;
      }
      anime.animate(b, { opacity: 0, y: '-.7em', duration: 260, ease: 'in(2)' });
      anime.animate(a, { opacity: [0, 1], y: ['.7em', '0em'], duration: 420, delay: 120, ease: 'out(3)' });
    }

    function show(el, state, instant) {
      var s = el._ssi;
      if (s.state === state) return;
      s.state = state;
      var done = state === 'done';
      var c = opt(el, done ? 'doneColor' : 'color');
      labels(el, done, instant);
      if (instant || reduced()) {
        if (s.spin) { s.spin.pause(); s.spin = null; }
        el.style.color = c;
        anime.utils.set(s.arrows, { rotate: 0, scale: done ? 0 : 1, opacity: done ? 0 : 1 });
        anime.utils.set(s.tick, { draw: done ? '0 1' : '0 0' });
        return;
      }
      anime.animate(el, { color: c, duration: 450, ease: 'out(2)' });
      if (done) {
        // Let the arrows finish their turn and shrink away, then draw the tick.
        if (s.spin) { s.spin.pause(); s.spin = null; }
        anime.animate(s.arrows, { rotate: '+=200', scale: 0, opacity: 0, duration: 420, ease: 'in(2)' });
        anime.animate(s.tick, { draw: ['0 0', '0 1'], duration: 380, delay: 300, ease: 'out(3)' });
        anime.animate(s.cloud, {
          scaleY: [{ to: 0.86, duration: 140, ease: 'out(2)' }, { to: 1, duration: 650, ease: 'outElastic(1, .45)' }],
          scaleX: [{ to: 1.08, duration: 140, ease: 'out(2)' }, { to: 1, duration: 650, ease: 'outElastic(1, .45)' }],
          delay: 380
        });
      } else {
        anime.animate(s.tick, { draw: '1 1', duration: 260, ease: 'in(2)' });
        anime.utils.set(s.arrows, { rotate: 0 });
        anime.animate(s.arrows, { scale: [0, 1], opacity: [0, 1], duration: 420, delay: 160, ease: 'outBack(2)' });
        s.spin = anime.animate(s.arrows, { rotate: [0, 360], duration: 1100, loop: true, ease: 'linear', autoplay: s.visible });
      }
    }

    function cycle(el) {
      var s = el._ssi;
      clearTimeout(s.timer);
      if (!s.visible) { s.waiting = true; return; }
      s.waiting = false;
      show(el, 'syncing');
      if (s.spin) s.spin.play();
      s.timer = setTimeout(function () {
        show(el, 'done');
        if (el.dataset.loop === undefined || el.dataset.loop === 'false') return;
        s.timer = setTimeout(function () { cycle(el); }, +opt(el, 'hold') + 600);
      }, +opt(el, 'duration'));
    }

    function init() {
      var io = new IntersectionObserver(function (entries) {
        entries.forEach(function (e) {
          var s = e.target._ssi;
          s.visible = e.isIntersecting;
          if (s.spin) { if (s.visible) s.spin.play(); else s.spin.pause(); }
          if (!s.visible) return;
          if (!s.started) { s.started = true; if (e.target.dataset.manual === undefined) cycle(e.target); else if (!s.state) show(e.target, 'syncing'); }
          else if (s.waiting) cycle(e.target);
        });
      }, { threshold: 0.3 });
      document.querySelectorAll('[data-sync-status-icon]').forEach(function (el) {
        if (el._ssi) return;
        setup(el);
        if (!reduced()) io.observe(el);
      });
    }

    window.animacious = window.animacious || {};
    window.animacious.syncStatus = function (el, state) {
      var s = el._ssi;
      if (!s) return;
      clearTimeout(s.timer);
      show(el, state === 'done' ? 'done' : 'syncing');
    };
    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-sync-status-icon style="font-size: 48px"></div>

<!-- driven by your own code: -->
<div id="sync" data-sync-status-icon data-manual data-done-text="Backed up"></div>
<script>
  const sync = document.getElementById("sync");
  saveEverything().then(() => animacious.syncStatus(sync, "done"));
</script>