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

AI Loading Animation: 9 Patterns by One Value

One ai loading animation value: A high orbit-radius value scatters particles wide; near zero they snap onto the core, hue value swings to done, and a preview

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

All nine below run on requestAnimationFrame and read exactly one thing to decide what's on screen -- a 0-to-1 progress value, an elapsed-time value, or a boolean that flips once. Nothing here calls a model, an API, or a streaming endpoint: item 08's "revealed" sentence is a fixed string already sitting in the page source, and the other eight just route the same kind of timer-driven number into a different CSS property or canvas draw call. The order below runs from the simplest single-property read -- item 01's ring recoloring by a hue value -- to the one where a value swaps the entire displayed markup for something else, item 09's reduced-motion badge.

Before the Nine Patterns, Here's Where Each One Breaks

Every one of the nine loops on a fixed two-second cycle (LOOP = 2.0), and that cycle has no idea how long a real request actually takes. Drop item 01 in front of a genuine API call and never wire up a stop condition, and the ring keeps recoloring toward its done hue and snapping back to the start hue every two seconds indefinitely -- a user who's still watching after the third or fourth loop reads that as broken, not as "still working," because nothing about the animation itself communicates that it's simulated. The AI_LOADER_HUE_TRANSITION_MS window in item 01 and the SWITCH_AT split in item 09 look like real progress markers, but they're calendar-time constants tied to LOOP, not to any actual completion event -- treat that as a demo default, not a promise. Managing that expectation means calling a real stop function (cancel the requestAnimationFrame, swap in a completed state) the moment your own request actually resolves, instead of letting any of the nine loop past the point where "processing" stopped being true.

Nine AI Loading Patterns, Measured

The numbers below come from each demo's own screen-recording pass: cumulative area is how much of the tile's pixels changed across the whole loop, average intensity is the average per-pixel byte difference where it changed, and moved frames counts how many of the 23 frame-to-frame deltas actually moved a pixel.

# Pattern Cumulative area Avg. intensity Moved frames
01 Halo Ring Hue Shift 22.139% 30.8 23/23
02 Orb Morph Pulse 30.089% 40.9 23/23
03 Particle Swirl Core 25.456% 60.7 23/23
04 Neural Node Pulse 11.583% 69.1 23/23
05 Card Content Stream Fill 14.256% 105.4 20/23
06 Button Icon Sparkle Morph 10.111% 175.4 23/23
07 Listening Waveform 17.854% 100.9 23/23
08 Ghost-text Caret Reveal 46.62% 52.6 20/23
09 Reduced-motion Static Badge 2.808% 120.3 17/23

Item 08's ghost-text reveal covers the most area at 46.62% because every letter in the sentence repaints its color on nearly every frame it's active, while item 09 sits lowest at 2.808% by design -- it's a static text badge meant to replace motion entirely once reduced-motion is detected, so a small footprint there is the point, not a shortfall. Item 09's 17/23 moved-frame count is also lower than the rest on purpose: the badge only needs to redraw once it appears and once more when the dots stop, not on every one of the 23 sampled deltas.

01Halo Ring AI Loader Whose Hue Value Turns Toward Done

A conic-gradient ring keeps spinning at a constant ROTATE_SPEED while a separate hue value only starts sliding toward the done color inside the last AI_LOADER_HUE_TRANSITION_MS of the loop -- a smooth() cubic ease blends BLUE into DONE across that window instead of a linear mix, so the color change reads as a settle rather than a snap. The ring and its core share the same interpolated color at every frame, which keeps the whole shape looking like one coherent object instead of two elements drifting out of sync.

Halo Ring AI Loader Whose Hue Value Turns Toward Done
Open in a new tab

01 · Halo Ring AI Loader Whose Hue Value Turns Toward Done — Measured area 22.139% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

conic-gradientAI_LOADER_HUE_TRANSITION_MSROTATE_SPEED
  function draw(sec) {
    var angle = (sec * ROTATE_SPEED) % 360;
    var p = (sec % LOOP) / LOOP;
    var winStart = 1 - (AI_LOADER_HUE_TRANSITION_MS / 1000) / LOOP;
    var mixT = p <= winStart ? 0 : smooth((p - winStart) / (1 - winStart));
    var col = mix(BLUE, DONE, mixT);
    var col2 = mix(BLUE, DONE, mixT * 0.6);

    ring.style.setProperty('--angle', angle + 'deg');
    ring.style.setProperty('--c1', rgbStr(col, 1));
    ring.style.setProperty('--c2', rgbStr(col2, .35));
    var corePulse = 1 + 0.08 * Math.sin(sec * Math.PI * 2 / LOOP * 3);
    core.style.setProperty('--core-scale', corePulse.toFixed(3));
    core.style.setProperty('--core-c', rgbStr(col, 1));

    var pct = Math.round(p * 100);
    pctEl.textContent = pct + '%';
  }

02Orb Pulse Whose Breathing Widens With a Growing Wait-time Value

An accum value climbs from 0 to 1 across the full loop and feeds easeSine(), so the orb's breathing amplitude itself grows the longer the loop has been running, on top of a separate BREATH_HZ sine wave that keeps the actual in-and-out motion going. Scale, blur, and glow all read off that same composite value each frame, so a wider breath always arrives with a softer edge and a brighter glow at once rather than any one property changing alone.

Orb Pulse Whose Breathing Widens With a Growing Wait-time Value
Open in a new tab

02 · Orb Pulse Whose Breathing Widens With a Growing Wait-time Value — Measured area 30.089% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

BREATH_HZAMP_BASEAMP_GROW
  function draw(sec) {
    var t = sec % LOOP;
    var accum = Math.min(sec, LOOP) / LOOP;      // how far the wait-time value has accumulated (0-1)
    var amp = AMP_BASE + AMP_GROW * easeSine(accum);
    var wave = Math.sin(t * Math.PI * 2 * BREATH_HZ);
    var scale = 1 + amp * wave;
    var blur = 12 + 9 * Math.max(0, wave);
    var glow = 0.35 + 0.25 * Math.max(0, wave);

    orb.style.setProperty('--scale', scale.toFixed(4));
    orb.style.setProperty('--blur', blur.toFixed(1) + 'px');
    orb.style.setProperty('--glow', glow.toFixed(3));

    var pct = Math.round((t / LOOP) * 100);
    pctEl.textContent = pct + '%';
  }

03Particle Swirl That Gathers Into the Core as an Orbit-radius Value Shrinks

Twenty-six particles start spread out at angles set by the golden angle (roughly 137.508 degrees apart, so no two ever land on the same ray), and each one's own orbit-radius value shrinks from MAX_R toward CORE_R as progress climbs, eased by AI_LOADER_ORBIT_SHRINK_EASE so the pull inward isn't linear. A per-particle jitter value keeps every orbit radius slightly different from its neighbors even at the same progress point, which is what keeps the swirl looking like a loose cloud instead of a single ring collapsing uniformly.

Particle Swirl That Gathers Into the Core as an Orbit-radius Value Shrinks
Open in a new tab

03 · Particle Swirl That Gathers Into the Core as an Orbit-radius Value Shrinks — Measured area 25.456% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

AI_LOADER_ORBIT_SHRINK_EASEGOLDENMAX_R
    var cx = W / 2, cy = H / 2;
    var p = (sec % LOOP) / LOOP;
    var shrink = Math.pow(p, 1 / AI_LOADER_ORBIT_SHRINK_EASE);   // orbit-radius shrink easing

    for (var i = 0; i < particles.length; i++) {
      var q = particles[i];
      var angle = q.baseAngle + sec * q.speed;
      var r = CORE_R + (MAX_R - CORE_R) * (1 - shrink) * q.jitter;
      var x = cx + r * Math.cos(angle);
      var y = cy + r * Math.sin(angle) * 0.86;
      ctx.beginPath();
      ctx.arc(x, y, q.size, 0, Math.PI * 2);
      ctx.fillStyle = i % 2 === 0 ? DOT : DOT_DEEP;
      ctx.shadowColor = DOT;
      ctx.shadowBlur = 10;
      ctx.fill();
      ctx.shadowBlur = 0;
    }

04Neural-node Pulse That Hops Along a Delay Value

A 4x3 grid of nodes is wired into edges, and every node's own delay value is set by its grid distance from the origin corner (dist * STEP, plus a small per-node jitter), so the pulse always starts at the top-left and radiates outward rather than lighting up randomly. Each edge inherits the smaller of its two endpoint delays, and once local time passes that delay it draws a second, brighter line over the dim one, growing from 0% to 100% of the edge's length over EDGE_MS -- the light genuinely travels from node to node instead of every line brightening at once.

Neural-node Pulse That Hops Along a Delay Value
Open in a new tab

04 · Neural-node Pulse That Hops Along a Delay Value — Measured area 11.583% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

STEPEDGE_MSPULSE_W
    for (var i = 0; i < edges.length; i++) {
      var ed = edges[i];
      var pa = pos(nodes[ed.a]), pb = pos(nodes[ed.b]);
      ctx.beginPath();
      ctx.moveTo(pa.x, pa.y);
      ctx.lineTo(pb.x, pb.y);
      ctx.strokeStyle = LINE_DIM;
      ctx.lineWidth = 2;
      ctx.stroke();

      var local = t - ed.delay;
      if (local > 0) {
        var frac = clamp01(local / EDGE_MS);
        ctx.beginPath();
        ctx.moveTo(pa.x, pa.y);
        ctx.lineTo(pa.x + (pb.x - pa.x) * frac, pa.y + (pb.y - pa.y) * frac);
        ctx.strokeStyle = LINE_LIT;
        ctx.lineWidth = 3;
        ctx.stroke();
      }
    }

05Result-card Stream Loading Where a Filled-line Count Value Climbs

Six skeleton lines each start their own fill on a staggered offset set by LINE_MS times their index, then spend FILL_MS sliding from 0% to 100% width with a smooth() ease while their color mixes from a flat SKELETON tone to the real TEXT tone at the same rate as the width grows. A doneCount tally only increases once a line's local progress actually reaches 1, so the percentage badge always matches how many lines are fully solid, not how many have merely started.

Result-card Stream Loading Where a Filled-line Count Value Climbs
Open in a new tab

05 · Result-card Stream Loading Where a Filled-line Count Value Climbs — Measured area 14.256% · moving frames 20/23Original by GODRICH · SCSS and React ship in the zip · See the password

LINE_MSFILL_MS--skeleton
  function draw(sec) {
    var t = sec % LOOP;
    var doneCount = 0;
    for (var i = 0; i < lines.length; i++) {
      var local = t - i * LINE_MS;
      var raw = clamp01(local / FILL_MS);
      var eased = smooth(raw);
      fills[i].style.width = (eased * 100).toFixed(1) + '%';
      fills[i].style.background = rgbStr(mix(SKELETON, TEXT, eased));
      lines[i].classList.toggle('is-active', raw > 0 && raw < 1);
      if (raw >= 1) doneCount++;
    }
    pctEl.textContent = Math.round((doneCount / lines.length) * 100) + '%';
  }

06Button Icon That Morphs to a Sparkle When a Done Value Flips

Both the spinner shape and the sparkle shape are stored as the same eight radius values at different magnitudes (R_RING for the spinner, alternating R_OUT/R_IN for the sparkle points), so the moment the DONE boolean flips true, morphT eases those eight radii from one shape's numbers to the other's over MORPH_MS instead of swapping two separate SVG paths outright. A short scale pulse rides along for the first half-second after the flip, and the spinner's own rotation slows to 12% of its speed once done, so the icon settles instead of still spinning beneath a shape that's supposed to be finished.

Button Icon That Morphs to a Sparkle When a Done Value Flips
Open in a new tab

06 · Button Icon That Morphs to a Sparkle When a Done Value Flips — Measured area 10.111% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

pathDONEMORPH_MS
    var t = sec % LOOP;
    var localFlip = t - FLIP_AT;

    if (localFlip >= 0) { DONE = true; } else { DONE = false; }

    var morphT = DONE ? smooth(clamp01(localFlip / MORPH_MS)) : 0;
    var radii = [];
    for (var i = 0; i < 8; i++) {
      var spinnerR = R_RING;
      var sparkleR = (i % 2 === 0) ? R_OUT : R_IN;
      radii[i] = spinnerR + (sparkleR - spinnerR) * morphT;
    }
    var angleOffset = (sec * ROT_SPEED) % 360 * Math.PI / 180;
    var rot = DONE ? angleOffset * 0.12 : angleOffset;
    path.setAttribute('d', buildPath(radii, rot));

    var pulse = 1;
    if (DONE && localFlip < 0.5) {
      pulse = 1 + 0.22 * Math.sin(clamp01(localFlip / 0.5) * Math.PI);
    }
    path.setAttribute('transform', 'scale(' + pulse.toFixed(3) + ')');
    path.setAttribute('fill', DONE ? 'var(--done)' : 'var(--spin)');

    pctEl.textContent = DONE ? 'Done' : 'Processing';

07Listening Waveform Where an Input-level Value Shakes Bar Heights

Twenty bars each carry two seeded, fixed sine frequencies (freqA for the main envelope, freqB for a smaller jitter riding on top), composited every frame into one input-level value per bar that drives its height between BASE_H and MAX_H. None of it touches a microphone -- there's no getUserMedia call anywhere in the file -- seeded() is a deterministic pseudo-random function keyed off each bar's index, so the waveform looks organic while staying exactly reproducible on every load.

Listening Waveform Where an Input-level Value Shakes Bar Heights
Open in a new tab

07 · Listening Waveform Where an Input-level Value Shakes Bar Heights — Measured area 17.854% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

seededfreqABASE_H
  function draw(sec) {
    var t = sec;
    for (var i = 0; i < N; i++) {
      var q = bars[i];
      var env = 0.5 + 0.5 * Math.sin(t * q.freqA * Math.PI * 2 + q.phase);
      var jit = Math.sin(t * q.freqB * Math.PI * 2 + q.phase * 1.7) * 0.22;
      var level = clamp01(env + jit);
      var h = BASE_H + (MAX_H - BASE_H) * level;
      bels[i].style.height = h.toFixed(1) + 'px';
      bels[i].style.opacity = (0.55 + level * 0.45).toFixed(2);
    }
    var avg = 0;
    for (var j = 0; j < N; j++) avg += parseFloat(bels[j].style.height);
    avg = Math.round((avg / N - BASE_H) / (MAX_H - BASE_H) * 100);
    pctEl.textContent = avg > 8 ? 'Listening' : 'Waiting';
  }

08Ghost-text Reveal That Shows One More Letter as a Token-index Value Rises

A fixed sentence is split into a character array once at load, and a token-index value climbing by AI_LOADER_TOKEN_REVEAL_MS per step decides how many of those characters have crossed from a faint ghost color to real text color, each one fading over its own FADE_MS window rather than snapping. A caret element tracks the boundary between revealed and unrevealed letters using offsetLeft/offsetTop on the current character, so it visually leads the reveal the same way a text cursor would, blinking on a separate BLINK_MS timer underneath.

Ghost-text Reveal That Shows One More Letter as a Token-index Value Rises
Open in a new tab

08 · Ghost-text Reveal That Shows One More Letter as a Token-index Value Rises — Measured area 46.62% · moving frames 20/23Original by GODRICH · SCSS and React ship in the zip · See the password

AI_LOADER_TOKEN_REVEAL_MSFADE_MSBLINK_MS
  function draw(sec) {
    if (!GHOST) readColors();
    var t = sec % LOOP;
    var elapsedMs = t * 1000;
    var counter = Math.max(0, Math.min(LEN, Math.floor(elapsedMs / AI_LOADER_TOKEN_REVEAL_MS)));

    for (var i = 0; i < LEN; i++) {
      var revealAt = i * AI_LOADER_TOKEN_REVEAL_MS;
      var fade = clamp01((elapsedMs - revealAt) / FADE_MS);
      els[i].style.color = rgbStr(mix(GHOST, REAL, fade));
    }

    var target = counter < LEN ? els[counter] : els[LEN - 1];
    if (target) {
      var left = counter < LEN ? target.offsetLeft : target.offsetLeft + target.offsetWidth;
      var top = target.offsetTop;
      caret.style.transform = 'translate(' + left.toFixed(1) + 'px,' + top.toFixed(1) + 'px)';
      fill.style.width = left.toFixed(1) + 'px';
    }
    var blinkOn = Math.floor(elapsedMs / BLINK_MS) % 2 === 0;
    caret.style.opacity = blinkOn ? '1' : '.15';
  }

09Processing Badge That Swaps to Static Text When a Reduced-motion Value Is Detected

prefers-reduced-motion is read once, and this item simulates the moment that value would flip on partway through the loop at SWITCH_AT: before it, three dots bounce on independent BOUNCE_HZ sine phases; after it, a badge fades in and scales up through a critically-damped spring() approximation that slightly overshoots past 1 before settling -- all of it standing in for whatever real detection your own code would run once instead of every frame. Every other item in this set still animates normally when reduced motion is off -- this one exists specifically to show what all eight should collapse into when it's on.

Processing Badge That Swaps to Static Text When a Reduced-motion Value Is Detected
Open in a new tab

09 · Processing Badge That Swaps to Static Text When a Reduced-motion Value Is Detected — Measured area 2.808% · moving frames 17/23Original by GODRICH · SCSS and React ship in the zip · See the password

SWITCH_ATspringBOUNCE_HZ
  function draw(sec) {
    var t = sec % LOOP;
    var on = t >= SWITCH_AT;   // simulated reduced-motion value

    if (on) {
      var localT = (t - SWITCH_AT) / SPRING_MS;
      var s = spring(localT);
      badge.style.opacity = '1';
      badge.style.transform = 'scale(' + (0.7 + 0.3 * s).toFixed(3) + ')';
      dots.length && (root.querySelector('.ld__dots').style.opacity = '0');
      stateEl.textContent = 'Reduced motion: on · processing badge';
    } else {
      badge.style.opacity = '0';
      badge.style.transform = 'scale(.7)';
      root.querySelector('.ld__dots').style.opacity = '1';
      for (var i = 0; i < dots.length; i++) {
        var phase = i * (Math.PI * 2 / 3);
        var y = Math.sin(t * BOUNCE_HZ * Math.PI * 2 + phase);
        dots[i].style.transform = 'translateY(' + (y * 7).toFixed(1) + 'px)';
        dots[i].style.opacity = (0.55 + 0.45 * (0.5 + 0.5 * y)).toFixed(2);
      }
      stateEl.textContent = 'Reduced motion: off · moving';
    }
  }

How This Differs From the Chat, Button, and Physics Loaders

The nine AI chat loading animations are built around a message bubble and a three-dot or streaming-text metaphor specific to chat UI; the nine above are standalone indicators meant to sit on a button, a card header, or a toolbar icon, not inside a conversation thread -- item 05's card-stream fill is the closest relative, but it fills skeleton lines inside a result card rather than typing a reply into a bubble. The nine loading-button states model a single element's full lifecycle from idle through a spinner to a success checkmark on click; item 06 above borrows that same idle-to-done shape but keeps the "waiting" state animated indefinitely on a timer instead of a state machine driven by a promise resolving.

The nine canvas-physics loaders compute real physical simulations -- fluid surfaces, particle swarms with collision, cellular automata -- and item 03's particle swirl and item 04's node-and-edge canvas both share that same ctx.fillRect/ctx.arc rendering approach. The difference is what each number represents: those nine loaders' values feed a physics step, while item 03's and item 04's values are just an orbit radius and a propagation delay respectively, with no collision or force calculation involved at all.

Three Variants to Bend, and What prefers-reduced-motion Removes

Variant Changed value What it should feel like
Adjust the halo-ring hue-transition speed value 01 AI_LOADER_HUE_TRANSITION_MS 640 → 260 Shortening the value makes the color jump over even at a small progress rise, looking almost done too early; lengthening it holds the color back until near the end, which should read as more relaxed.
Adjust the particle orbit-shrink easing value 03 AI_LOADER_ORBIT_SHRINK_EASE 2.2 → 4.8 Keeping the easing value near-linear makes particles gather toward the core at a steady pace; pushing it to a steep curve should feel like barely any movement at first, then a sudden rush inward near the end.
Adjust the ghost-text token-reveal interval value 08 AI_LOADER_TOKEN_REVEAL_MS 62 → 26 Shortening the interval value should make letters spill out almost all at once; lengthening it should bring the reveal closer to a readable, one-letter-at-a-time pace.

All nine read prefers-reduced-motion once, when the page first loads, not on every frame -- when that media query reports reduce, the loop never starts and a single still frame draws instead at a fixed point in the cycle (item 09 draws its own already-settled badge state rather than the dots). Every tile also carries role="application", tabindex="0", and an aria-label, and the same handler answers a click, an Enter key, a Space key (both toggle the loop), and an R key (restart), plus a resize listener that redraws the tile at its new size, so a keyboard-only visitor can replay, pause, or restart any of the nine without a mouse. The nine source files, one per pattern, sit behind 379w2wxy in the same folder as this article's demo -- open one before pushing any of the three values above toward its variant target, since a value that never gets exercised in the measurement table can still leave a real processing state looking stuck.

Where You'd Actually Use This

Item 06's button-icon morph is the one to reach for on an inline AI action button -- "summarize," "rewrite," a toolbar wand icon -- because it keeps the button's footprint identical before and after, just swapping what the icon inside it looks like once DONE flips, so nothing else in the layout has to shift. Item 05's card-stream fill suits an AI summary or report card that's genuinely filling in section by section, since the skeleton-to-text color mix on each line only advances once that line's real content would actually be ready, rather than animating uniformly across content that isn't there yet.

Item 09 belongs everywhere the other eight get used, not as an alternative pattern but as the fallback every one of them needs -- any product shipping an AI loading animation css treatment should be pairing it with something that collapses to plain text the moment a visitor's OS reports reduced motion, and item 09's badge is exactly that collapse, already built and already measured above.

Where Else AI Processing Shows Up

An ai loading animation codepen search turns up plenty of single-element spinners on a @keyframes loop, and those are a fine starting point, but most of them don't separate "still working" from "just finished" the way item 01's hue shift or item 06's shape morph do -- a keyframe loop only interpolates between two fixed states, it doesn't know whether your request actually resolved. If you're comparing against an ai loading animation generator instead of hand-writing constants like the nine above, check whether it exposes a real stop condition at all -- the trap section further up applies just as much to a generated pattern as to one you wrote yourself.

Questions About These AI Loading Patterns

Is any of the nine connected to a real AI model, API, or streaming response?

No. All nine read a local timer value through requestAnimationFrame; item 08's revealed sentence is a fixed string already in the page source, and none of the files behind this article's zip make a network request of any kind.

Can item 08's ghost-text reveal work as a plain typewriter effect outside an AI context?

Yes -- swap SENTENCE for any string and leave AI_LOADER_TOKEN_REVEAL_MS, FADE_MS, and BLINK_MS as they are; the reveal logic itself has no dependency on the sentence meaning anything AI-related, it just walks a character array.

Why does item 09 show only 17 of 23 moved frames when it's the reduced-motion pattern?

Because a static badge is supposed to sit still most of the time -- it only needs to redraw once when the dots fade out and the badge fades in, and once more when the spring settles, not on every sampled frame; a higher moved-frame count there would mean it wasn't actually reduced. Swapping the DOM badge for an inline ai loading animation svg would keep that same low frame count, since the underlying value driving it doesn't change.

Sources: MDN — prefers-reduced-motion · MDN — conic-gradient() · MDN — Using the progressbar role · MDN — Using the status role · MDN — Window.requestAnimationFrame()

Related reading: 9 CSS AI Chat Loading Animations to Copy-Paste · Loading Button UI — 9 States, Spinner to Success · 9 CSS Loading Animation Patterns, Canvas Physics -- the first swaps every one of these nine for a chat-bubble-specific metaphor, the second models a single button's full click-to-success lifecycle instead of an open-ended wait, and the third trades DOM elements for canvas pixels while keeping the same one-value-drives-everything approach items 03 and 04 above use.

Enter the archive password

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