GODRICH
#css-animation#button#loader#transition#scroll#dashboard#ui#design-trends

Autosave Indicator: 9 Save-Status Patterns

In this autosave collection: One of nine repeats a saving label with dots, another turns elapsed time into a minutes-ago phrase every second, and another

Auto-plays · click a tile to jump to its section · all nine in one zip

All nine react to the same trigger — a real click on a button (07 has two, one per resolution choice; 09's listener sits on its whole panel rather than a button) — and none of them leave an outcome to chance: every value moves by a sine wave, an exponential backoff formula, a spring integration, a modulo-based stagger, or an SVG path length, never by Math.random(). Nothing here calls fetch(), reads navigator.onLine, or touches localStorage — the "saving," the "offline queue," and the "conflict" are local variables recalculated in the browser, so no claim below should be read as "the server confirms the save" or "this detects your real connection." Item 01 is also the only one of the nine to mark its dot row aria-hidden, since a screen reader has no use for three purely decorative dots.

Anatomy of nine patterns — which value changes and when

Three items (01, 05, 09) turn elapsed time straight into a sine-wave opacity value: 01's label and dots pulse while a save is in progress, 05's dot fades as a saved timestamp gets stale, and 09 runs that same sine curve on one panel while a second panel right beside it changes with no tween at all. Two items (02, 08) draw an SVG length instead of tweening a percentage: 02's checkmark grows along its own getTotalLength(), and 08's ring subtracts from a fixed circumference every tick to count a cycle down. Item 03 and item 04 both clamp a counter between two bounds, but 03's remaining-retries count feeds an exponential backoff wait (BACKOFF_BASE_MS x 2^n) while 04's pending-count value only drives a linear scaleX fill bar. Item 06 walks the same three-stage counter across three dots with a modulo offset, so each dot lags the one before it by a fixed step, and item 07 is the only one of the nine integrating a spring (SPRING_K, SPRING_C) frame by frame rather than computing a value from elapsed time directly. None of this amounts to a single auto save toggle flipping once — every one of the nine keeps recalculating its own value on a repeating schedule for as long as the demo runs.

Code per pattern

01Saving-label pulse — the saving label's brightness value rises and falls repeatedly while a save is in progress

While a save is in progress, the label's brightness value cycles low and high on a steady interval, and three dots pulse the same curve staggered by a fixed offset each. Once the phase crosses SAVING_MS, every opacity locks to a fixed number and the label swaps to its saved value instead of continuing the sine curve.

Saving-label pulse — the saving label's brightness value rises and falls repeatedly while a save is in progress
Open in a new tab

01 · Saving-label pulse — the saving label's brightness value rises and falls repeatedly while a save is in progress — Measured area 0.408% · moving frames 19/23Original by GODRICH · SCSS and React ship in the zip · See the password

PULSE_PERIOD_MSDOT_STAGGER_MSSAVING_MS
  function raf(ts) {
    if (phaseStart === null) phaseStart = ts;
    var el = ts - phaseStart;
    if (phase === 'saving') {
      if (reduce) {
        label.style.opacity = '1';
        dots.forEach(function (d) { d.style.opacity = '.6'; });
      } else {
        var t = (el % PULSE_PERIOD_MS) / PULSE_PERIOD_MS;
        var op = 0.45 + 0.55 * (0.5 + 0.5 * Math.sin(t * Math.PI * 2));
        label.style.opacity = op.toFixed(3);
        dots.forEach(function (d, i) {
          var dop = 0.2 + 0.8 * Math.abs(Math.sin((el - i * DOT_STAGGER_MS) / PULSE_PERIOD_MS * Math.PI));
          d.style.opacity = dop.toFixed(3);
        });
      }
      if (el > SAVING_MS) {
        phase = 'idle';
        phaseStart = ts;
        label.textContent = 'Saved';
        label.style.opacity = '1';
        dots.forEach(function (d) { d.style.opacity = '0'; });
        root.classList.add('ssp--done');
      }

What the numbers say

Measured across 23 tracked frames, item 01 moves only 0.41% of the frame area at a 39.1 intensity score over 19 moving frames — a whisper-thin pulse confined to a short label and three dots, not a scene.

02Saved-flag flash — the completion value flips to one the instant a save finishes, then falls back to zero and disappears

The instant a save finishes, the completion value flips to one and the checkmark briefly appears, then after a set hold time it returns to zero and disappears. The checkmark itself is drawn, not swapped in as an icon: its stroke offset moves from full length to zero along the path's own measured length.

Saved-flag flash — the completion value flips to one the instant a save finishes, then falls back to zero and disappears
Open in a new tab

02 · Saved-flag flash — the completion value flips to one the instant a save finishes, then falls back to zero and disappears — Measured area 0.64% · moving frames 11/23Original by GODRICH · SCSS and React ship in the zip · See the password

DRAW_MSHOLD_MSGAP_MS
  function draw(cb) {
    if (reduce) { check.style.strokeDashoffset = '0'; cb(); return; }
    var start = null;
    function frame(ts) {
      if (start === null) start = ts;
      var t = Math.min(1, (ts - start) / DRAW_MS);
      check.style.strokeDashoffset = (len * (1 - t)).toFixed(2);
      if (t < 1) requestAnimationFrame(frame); else cb();
    }
    requestAnimationFrame(frame);
  }

  function cycle() {
    badge.classList.add('ssc__badge--show');
    draw(function () {
      timer = setTimeout(function () {
        badge.classList.remove('ssc__badge--show');
        timer = setTimeout(function () {
          check.style.strokeDashoffset = String(len);
          timer = setTimeout(cycle, GAP_MS);
        }, reduce ? 0 : FADE_MS);
      }, HOLD_MS);
    });
  }

What the numbers say

Measured across 23 tracked frames, item 02 moves only 0.64% of the frame area at a 53.4 intensity score over 11 moving frames — a quick, self-contained flash that returns to nothing once the checkmark fades.

03Save-failure retry count — the remaining-retries value drops by one and the label shakes each time a save fails

Each time a save fails, the remaining-retries value drops by one, and at that instant the label shakes briefly left and right. Once retries reach zero, the wait before each retry has already doubled through a fixed backoff formula rather than the demo staying silent about how long the next attempt is delayed.

Save-failure retry count — the remaining-retries value drops by one and the label shakes each time a save fails
Open in a new tab

03 · Save-failure retry count — the remaining-retries value drops by one and the label shakes each time a save fails — Measured area 1.646% · moving frames 10/23Original by GODRICH · SCSS and React ship in the zip · See the password

MAX_RETRYSHAKE_MSBACKOFF_BASE_MS
  function fail() {
    retries -= 1;
    label.textContent = 'Save failed';
    root.classList.add('ser--fail');
    shake(function () {
      if (retries > 0) {
        countEl.textContent = 'retries left ' + retries;
        var wait = BACKOFF_BASE_MS * Math.pow(2, MAX_RETRY - retries - 1);
        timer = setTimeout(fail, wait);
      } else {
        countEl.textContent = 'Retries exhausted';
        root.classList.add('ser--out');
        timer = setTimeout(reset, 900);
      }
    });
  }

What the numbers say

Measured across 23 tracked frames, item 03 moves only 1.65% of the frame area at a 109.5 intensity score over 10 moving frames — a short, sharp shake that only touches a few characters near the failure count.

04Offline-queue count — the pending-save count value climbs while disconnected and counts back down to zero the instant the connection returns

While disconnected, the pending-count value rises by one at a time, and the instant the connection returns, that value counts back down to zero in sequence. A fill bar tied to the same count clamps between zero and its maximum with Math.min/Math.max, so it never overshoots either end.

Offline-queue count — the pending-save count value climbs while disconnected and counts back down to zero the instant the connection returns
Open in a new tab

04 · Offline-queue count — the pending-save count value climbs while disconnected and counts back down to zero the instant the connection returns — Measured area 1.761% · moving frames 22/23Original by GODRICH · SCSS and React ship in the zip · See the password

MAX_QUEUEADD_MSDRAIN_MS
  function paint() {
    countEl.textContent = count + 'queued';
    fill.style.transform = 'scaleX(' + (count / MAX_QUEUE).toFixed(3) + ')';
    label.textContent = online ? 'Online' : 'Offline';
    root.classList.toggle('soq--online', online);
  }

  function tick() {
    if (!online) {
      count = Math.min(MAX_QUEUE, count + 1);
      paint();
      if (count >= MAX_QUEUE) { online = true; paint(); timer = setTimeout(tick, DRAIN_MS); return; }
      timer = setTimeout(tick, ADD_MS);
    } else {
      count = Math.max(0, count - 1);
      paint();
      if (count <= 0) { timer = setTimeout(function () { online = false; paint(); timer = setTimeout(tick, ADD_MS); }, 500); return; }
      timer = setTimeout(tick, DRAIN_MS);
    }
  }

What the numbers say

Measured across 23 tracked frames, item 04 moves 1.76% of the frame area at a 148.7 intensity score over 22 moving frames — a small bar and counter, but one that moves on almost every one of the 23 frames as it drains and refills.

05Last-saved timestamp refresh — the elapsed-time-since-save value recalculates every second and turns into a changing phrase

After a save finishes, the elapsed-time value recalculates every second, and that value keeps turning into a changing minutes-ago phrase. A freshness value tied to the same elapsed time fades the status dot toward a fixed floor as the timestamp gets older, unless reduced motion holds it at full opacity instead.

Last-saved timestamp refresh — the elapsed-time-since-save value recalculates every second and turns into a changing phrase
Open in a new tab

05 · Last-saved timestamp refresh — the elapsed-time-since-save value recalculates every second and turns into a changing phrase — Measured area 0.822% · moving frames 21/23Original by GODRICH · SCSS and React ship in the zip · See the password

DEMO_SEC_MSMIN_THRESHOLD_SECRESET_AFTER_SEC
  var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
  var savedAt = performance.now();

  // Calculation layer: elapsed seconds = (now - savedAt) / accelerated-ms; the phrase text and the dot's freshness opacity (1 - min(1, elapsed / threshold)) both come from that one value
  function raf(ts) {
    var elapsedSec = Math.floor((ts - savedAt) / DEMO_SEC_MS);
    if (elapsedSec >= RESET_AFTER_SEC) { savedAt = ts; elapsedSec = 0; }
    var phrase;
    if (elapsedSec < 5) phrase = 'Saved just now';
    else if (elapsedSec < MIN_THRESHOLD_SEC) phrase = elapsedSec + 'sec ago';
    else phrase = Math.floor(elapsedSec / 60) + 'min ago';
    label.textContent = phrase;
    var fresh = 1 - Math.min(1, elapsedSec / RESET_AFTER_SEC);
    dot.style.opacity = reduce ? '1' : (0.35 + 0.65 * fresh).toFixed(3);
    requestAnimationFrame(raf);
  }

What the numbers say

Measured across 23 tracked frames, item 05 moves only 0.82% of the frame area at a 104.4 intensity score over 21 moving frames — a value that keeps recalculating on almost every frame even though little of the frame area itself changes.

06Status-dot color cycle — the dot's color value changes in sequence across idle, in-progress, and done stages

Each time the stage moves from idle to in-progress and from in-progress to done, the dot's color value changes in sequence. Every dot shares one stage counter through a modulo formula, so the three dots trade places one step apart instead of any single dot doing most of the work.

Status-dot color cycle — the dot's color value changes in sequence across idle, in-progress, and done stages
Open in a new tab

06 · Status-dot color cycle — the dot's color value changes in sequence across idle, in-progress, and done stages — Measured area 0.888% · moving frames 18/23Original by GODRICH · SCSS and React ship in the zip · See the password

STAGE_COUNTSTEP_MSCLASSES
  // Calculation layer: stage count, the interval between stage advances (accelerated ms), and the label text plus CSS class for each stage
  var STAGE_COUNT = 3;
  var STEP_MS = 480;
  var NAMES = ['Idle', 'In progress', 'Done'];
  var CLASSES = ['sdc__dot--idle', 'sdc__dot--busy', 'sdc__dot--done'];

  var stageIndex = 0;
  var timer = null;

  // Calculation layer: dot i's displayed stage = (current stage - i + stage count) mod stage count, so each dot's value lags one step behind the dot before it
  function paint() {
    label.textContent = NAMES[stageIndex];
    dots.forEach(function (d, i) {
      var s = ((stageIndex - i) % STAGE_COUNT + STAGE_COUNT) % STAGE_COUNT;
      d.className = 'sdc__dot ' + CLASSES[s];
    });
  }
  paint();

  function step() {
    stageIndex = (stageIndex + 1) % STAGE_COUNT;
    paint();
    timer = setTimeout(step, STEP_MS);
  }

What the numbers say

Measured across 23 tracked frames, item 06 moves only 0.89% of the frame area at a 60.4 intensity score over 18 moving frames — three dots trading places rather than any one dot carrying most of the motion.

07Save-conflict banner — a notice value appears and waits for a choice when the same item was already saved elsewhere

When the same item is detected as already saved elsewhere, the notice's appearance value flips to one and stays on screen, only returning to zero after a choice is made. That slide is a spring, not a tween: velocity and position both integrate forward every frame from a stiffness and a damping constant, so the banner overshoots slightly and settles instead of stopping dead.

Save-conflict banner — a notice value appears and waits for a choice when the same item was already saved elsewhere
Open in a new tab

07 · Save-conflict banner — a notice value appears and waits for a choice when the same item was already saved elsewhere — Measured area 11.108% · moving frames 10/23Original by GODRICH · SCSS and React ship in the zip · See the password

SPRING_KSPRING_CAUTO_RESOLVE_MS
  var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
  var pos = -60;
  var vel = 0;
  var target = -60;
  var rafId = null;
  var timer = null;

  // Calculation layer: integrate the banner position with spring-damper physics -- velocity += (-K * displacement - C * velocity) * dt, position += velocity * dt
  function tick(ts, last) {
    var dt = Math.min(32, ts - last) / 1000;
    var disp = pos - target;
    var acc = -SPRING_K * disp - SPRING_C * vel;
    vel += acc * dt;
    pos += vel * dt;
    banner.style.transform = 'translateY(' + pos.toFixed(2) + '%)';
    if (Math.abs(disp) > 0.3 || Math.abs(vel) > 0.5) {
      rafId = requestAnimationFrame(function (t) { tick(t, ts); });
    } else {
      pos = target;
      banner.style.transform = 'translateY(' + pos.toFixed(2) + '%)';
    }
  }

What the numbers say

Measured across 23 tracked frames, item 07 moves 11.11% of the frame area at a 127.8 intensity score over 10 moving frames — the largest area of the nine, a whole banner sliding and settling rather than a small control.

08Next-autosave countdown — the time-remaining value counts down toward zero in a ring

The time-remaining value until the next autosave starts at a set number and keeps counting down toward zero on a fixed tick. A ring drawn from a save icon svg style outline reflects the same countdown by subtracting from its own circumference every tick rather than swapping in a bundled icon asset.

Next-autosave countdown — the time-remaining value counts down toward zero in a ring
Open in a new tab

08 · Next-autosave countdown — the time-remaining value counts down toward zero in a ring — Measured area 2.631% · moving frames 18/23Original by GODRICH · SCSS and React ship in the zip · See the password

CYCLE_SECRDEMO_TICK_MS
  function paint() {
    numEl.textContent = String(remaining);
    var offset = CIRC * (1 - remaining / CYCLE_SEC);
    prog.style.strokeDashoffset = offset.toFixed(2);
    root.classList.toggle('sac--saving', remaining <= 0);
  }

  function start() {
    remaining = CYCLE_SEC;
    root.classList.remove('sac--saving');
    paint();
    if (timerId) clearInterval(timerId);
    timerId = setInterval(function () {
      remaining -= 1;
      if (remaining <= 0) {
        remaining = 0;
        paint();
        clearInterval(timerId);
        timerId = setTimeout(start, 500);
        return;
      }
      paint();
    }, DEMO_TICK_MS);
  }

What the numbers say

Measured across 23 tracked frames, item 08 moves 2.63% of the frame area at a 158.9 intensity score over 18 moving frames — a ring and a number that update on a steady clock rather than in a single burst.

09Reduced-motion static save notice — with reduced motion, only the label's text value changes instead of animating

When reduced motion is on, position and size values do not move, and only the label's text value changes in place. Two panels sit side by side sharing the same phase value: one panel's opacity tweens along a sine curve while the other never receives a JS opacity value at all, so it stays visually still by omission rather than by any dedicated styling rule.

Reduced-motion static save notice — with reduced motion, only the label's text value changes instead of animating
Open in a new tab

09 · Reduced-motion static save notice — with reduced motion, only the label's text value changes instead of animating — Measured area 2.274% · moving frames 16/23Original by GODRICH · SCSS and React ship in the zip · See the password

SAVING_MSIDLE_MSPULSE_PERIOD_MS
  var phase = 'saving';
  var phaseStart = null;

  // Calculation layer: the normal panel's brightness eases along a sine curve keyed to elapsed time, while the reduced-motion panel mirrors the same state value with no tween at all
  function raf(ts) {
    if (phaseStart === null) phaseStart = ts;
    var el = ts - phaseStart;
    if (phase === 'saving') {
      var t = (el % PULSE_PERIOD_MS) / PULSE_PERIOD_MS;
      badgeA.style.opacity = (0.5 + 0.5 * (0.5 + 0.5 * Math.sin(t * Math.PI * 2))).toFixed(3);
      if (el > SAVING_MS) {
        phase = 'idle'; phaseStart = ts;
        labelA.textContent = labelB.textContent = 'Saved';
        badgeA.style.opacity = '1';
        badgeA.classList.add('srm__badge--done'); badgeB.classList.add('srm__badge--done');
      }
    } else {
      if (el > IDLE_MS) {
        phase = 'saving'; phaseStart = ts;
        labelA.textContent = labelB.textContent = 'Saving';
        badgeA.classList.remove('srm__badge--done'); badgeB.classList.remove('srm__badge--done');
      }
    }
    requestAnimationFrame(raf);
  }

What the numbers say

Measured across 23 tracked frames, item 09 moves only 2.27% of the frame area at a 57.7 intensity score over 16 moving frames — the smallest-purpose area of the nine, which fits a component built to be compared by timing between two panels rather than by how much of the frame it covers.

Where values reshape a pattern — refresh interval, phrase-switch timing, color-stage criteria

Pulse period stretch. Item 01's PULSE_PERIOD_MS moves from 900 to 1800: stretching the cycle makes the saving label and its three dots breathe twice as slowly, a separate constant from item 09's own PULSE_PERIOD_MS of 500 — the two items don't share one pulse.

Dot-cycle stagger speed. Item 06's STEP_MS moves from 480 to 200: shortening the stage interval makes the three dots chase each other through idle, in-progress, and done far faster, without changing the modulo formula that decides which dot shows which color-stage.

Countdown cycle halved. Item 08's CYCLE_SEC moves from 10 to 5: cutting the cycle in half makes the ring spin twice as fast toward the next autosave, a distinct value from item 01's own SAVING_MS — how long a save itself appears to take never changes here, only how often the countdown restarts.

None of these nine patterns confirm anything with a real server on their own. 9 Micro Interactions CSS — One Line, Copy-Paste (018) works the calculation axis of a single element flipping state on one interaction, not a full status value cycling through phases like these nine. Toast Notification UI: 9 Stacking Patterns (255) works the axis of several values stacking, pushing, and clearing each other in a list, one layer above any single save indicator. Too Many Failed Login Attempts: 9 Patterns (359) works the axis of a failure count shrinking a retry budget toward a lockout, the closest sibling to item 03's own retry counter here. Every one of these nine sources sits in the zip below, ready once you type in 3z36yr9y.

Where it breaks — the instant offline returns, concurrent multi-tab saves, reduced motion

The instant offline returns

None of the nine items call navigator.onLine or listen for a real online event — item 04's online flag is a plain variable flipped only by its own button and by its own count reaching zero or its maximum, so nothing here reacts to an actual network drop. A demo that only counts a pending-save value up while online is false and back down once a click flips it true is showing the counting arithmetic, not a genuine offline detector.

Concurrent multi-tab saves

Opening the same item in a second tab starts a fully independent copy — none of the nine reads or writes localStorage, a BroadcastChannel, or any other channel a second tab could see, so two tabs can run item 07's conflict banner or item 04's queue count at once without either tab knowing the other exists. A real multi-tab conflict banner like item 07's would need exactly that shared channel to detect the other tab's save, which is the piece deliberately left out here.

Reduced motion

Six items — 01, 02, 03, 05, 07, and 09 — read window.matchMedia('(prefers-reduced-motion: reduce)') in their own script and branch on it directly: 01 and 09 skip the sine-wave opacity for a fixed value, 02 and 03 skip their draw or shake tween and jump straight to the end state, and 07 snaps its spring position straight to its target instead of integrating toward it. Items 04, 06, and 08 never check reduce in script at all; their only concession is a transition: none rule in each one's own SCSS media query, so the JS-driven fill, dot color swap, or ring offset still recalculates every frame exactly as before, just without a CSS transition riding on top of it. Item 09 layers a second behavior on top of its own branch: under reduced motion its autoplay swaps from requestAnimationFrame to a plain setInterval that jumps both panels' text and class list at once with no tween anywhere, which is also why its two panels can sit side by side and be compared on every single click.

Three variants and accessibility

Variant What changes What it doesn't touch
Pulse period stretch PULSE_PERIOD_MS 900 → 1800 (item 01) Item 09's own PULSE_PERIOD_MS of 500
Dot-cycle stagger speed STEP_MS 480 → 200 (item 06) The stage-lag modulo formula itself
Countdown cycle halved CYCLE_SEC 10 → 5 (item 08) Item 01's own SAVING_MS

Across the nine, only item 01 adds an aria-* attribute — its <span class="ssp__dots" aria-hidden="true"> hides three purely decorative dots from a screen reader; no other item adds a role, aria-live, or tabindex anywhere, so a status change here only reaches a screen reader if whoever adapts this code adds a live region themselves. None of the nine swaps in a bundled icon asset for its status change either — the closest is item 02's own drawn checkmark path, built from getTotalLength() rather than any image file. What is native: items 01 through 08 each trigger off a real <button type="button"> (07 has two, one per resolution choice), while item 09's trigger is a click listener on its whole panel rather than a button element, and none of the nine sets a native disabled attribute at any point.

FAQ

Why does a plain setInterval cover item 06 while 01, 05, and 08 need requestAnimationFrame?

Item 06 only needs to advance a stage index once every STEP_MS, so a plain setInterval is enough; 01, 05, and 08 recompute a sine curve, an elapsed-time phrase, or a ring offset that has to look continuous, so they stay on requestAnimationFrame for every rendered frame.

Does item 04's pending-count value ever reflect a real dropped connection?

No — online is a plain variable flipped only by the item's own button, so the pending-save count climbing and draining back to zero is arithmetic on a local flag, not a genuine offline detector.

Can I swap in my own icon instead of the built-in dot or checkmark?

Yes — swapping the dot, checkmark, or ring markup here for a save icon floppy disk glyph or any other icon doesn't touch any of the calculation logic above it, since every item only ever changes a class, an opacity, or a stroke value on whatever element it's given.

Sources: MDN — requestAnimationFrame · MDN — prefers-reduced-motion · MDN — SVGGeometryElement: getTotalLength() method

Enter the archive password

The password is inside this article. You will find it as you read.