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

9 CSS Loading Animation Patterns, Canvas Physics

A css loading animation: A 0.92 damping constant shrinks the gravity loader's bounce; the automaton loader turns one rule into a per-generation fill ratio;

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

Nine is not an arbitrary round number of patterns here — each of these nine css loading animation patterns reaches its own idea of "progress" in a different way: a rising water level (01), an arrival ratio among particles (02), a stack of settled blocks (03), a seed count feeding a Voronoi mosaic (04), a shrinking orbit radius (05), an eroding noise ring (06), a rippling spring mesh (07), a sweeping angle (08), and a cellular-automaton survival ratio (09) — so the order moves from the simplest single number driving a shape toward a full grid of cells voting on their own neighbors.

Demo

The 3×3 grid above is nine independent canvas elements, each running its own requestAnimationFrame loop against a plain 2D context — not a css loading animation codepen embed borrowed from someone else's pen, since every tile's exact source sits printed under its own pattern below. Click a tile, press Enter or Space on it, or resize the window, and that pattern recomputes its progress value for the frame at hand; nothing here waits on a CSS animation to finish, because no CSS animation is running underneath any of it.

Why a Loader Draws Its Own Progress Instead of Tweening a Bar

A CSS keyframe animation can only interpolate one property's own value — an opacity, a transform, a border width — from one number to another on a fixed timing curve. It can't count how many particles have reached a center point, sort blocks by how far they've fallen, or ask nine grid neighbors whether a cell should turn on. Each of the nine below reads one plain JavaScript number every animation frame — a water level, an arrival count, a bounce height, a seed count, an orbit radius, an erosion depth, a ripple radius, a sweep angle, a survival ratio — and draws straight from that number with canvas fill and stroke calls, so progress emerges from the physics computation instead of being read off a CSS percentage.

None of the nine reach for a real Math.random either: item 02's particle offsets and item 04's seed wobble both come from values fixed once when the page loads (per-particle phase and frequency, or a seeded wobble radius), so the same run replays identically on every refresh instead of reshuffling. There's no library, no WebGL context, and no CDN script anywhere in the nine files behind this article — every pixel on screen came from ctx.fillRect, ctx.arc, or a hand-written noise function.

What the Screen-recording Measurements Below Actually Count

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

# Pattern Cumulative area Avg. intensity Moved frames
01 Fluid Wave Fill Level 57.08% 187.0 23/23
02 Particle Swarm Converge Count 15.628% 41.7 23/23
03 Gravity Drop-height Stack 6.757% 145.2 23/23
04 Voronoi Cell Seed-count Grow 37.312% 82.1 23/23
05 Magnetic Orbit-radius Pull 11.671% 65.7 23/23
06 Noise Ring Erosion Thickness 8.508% 99.3 23/23
07 Spring-mesh Ripple Radius 22.819% 58.1 23/23
08 Orbit Satellite Arc-sweep 19.327% 51.2 23/23
09 Cellular-automaton Fill Percent 33.618% 43.4 23/23

Code Per Pattern

Each pattern below follows the same shape — read progress as a 0-to-1 number, run one physics or field computation on it, draw the result to the 2D context — with a different rule doing the middle step. Every block below is an exact, unedited slice of that pattern's own source file.

01Loader a Progress Value Fills Like Rising Liquid

Progress (0 to 1) is mapped to the canvas water-line height, with two overlapping sine waves shaking the surface so the loader fills like rising liquid as the value climbs. It fits file-upload progress modals and app splash loaders — anywhere a bare percentage reads as static. Click the tile, or press Enter or Space on it, to watch the two sine layers settle the surface again.

Loader a Progress Value Fills Like Rising Liquid
Open in a new tab

01 · Loader a Progress Value Fills Like Rising Liquid — Measured area 57.08% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

FREQcreateLinearGradientLIQUID_DEEP
function draw(sec) {
  ctx.clearRect(0, 0, W, H);
  ctx.fillStyle = PAPER;
  ctx.fillRect(0, 0, W, H);

  var t = sec % LOOP;
  var p = t / LOOP;                  // 0→1 progress — sawtooth: the loader loops, so it always climbs back from 0
  var y0 = level(p);

  function waveY(x) {
    return y0
      + AMP1 * Math.sin(x * FREQ + sec * SPEED1)
      + AMP2 * Math.sin(x * FREQ * 1.8 + sec * SPEED2);
  }

  ctx.beginPath();
  ctx.moveTo(0, H);
  ctx.lineTo(0, waveY(0));
  var step = 4;
  for (var x = 0; x <= W; x += step) ctx.lineTo(x, waveY(x));
  ctx.lineTo(W, H);
  ctx.closePath();
  var grad = ctx.createLinearGradient(0, y0 - 24, 0, H);
  grad.addColorStop(0, LIQUID);
  grad.addColorStop(1, LIQUID_DEEP);

02Loader an Arrived-particle Count Value Fills

The ratio of particles that have arrived at the center to the total scattered count becomes the progress value, and the center disc brightens as more particles converge. It suits AI response wait screens and dashboard data-aggregation loaders, where a spinner alone hides how much work is actually done. Resize the window and the swarm redraws at the same arrival ratio.

Loader an Arrived-particle Count Value Fills
Open in a new tab

02 · Loader an Arrived-particle Count Value Fills — Measured area 15.628% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

createRadialGradientglobalAlphaPAPER_FADE
function draw(sec) {
  // Instead of a full clear, a faint fill repaints the canvas each frame — trails linger
  // where particles have passed, so the swarm reads as alive (this is why pixel change runs high).
  ctx.fillStyle = PAPER_FADE;
  ctx.fillRect(0, 0, W, H);

  var t = sec % LOOP;
  var p = t / LOOP;                              // 0→1 progress — target share of particles that have arrived
  var arrived = Math.floor(p * N);
  var cx = W * 0.5, cy = H * 0.5;

  for (var i = 0; i < N; i++) {
    var q = particles[i];
    var tx, ty;
    if (q.rank < arrived) {
      var orbit = 5 + 3 * Math.sin(sec * q.jf + q.jp);
      tx = cx + Math.cos(sec * 1.4 + q.jp) * orbit;
      ty = cy + Math.sin(sec * 1.4 + q.jp) * orbit;
    } else {
      tx = q.hx * W + Math.sin(sec * q.jf + q.jp) * 4;
      ty = q.hy * H + Math.cos(sec * q.jf * 0.8 + q.jp) * 4;
    }
    q.px += (tx - q.px) * 0.14;
    q.py += (ty - q.py) * 0.14;
  }

03Loader a Gravity Drop-height Value Stacks

The number of blocks is set by the progress value and each drops from the top under gravity, its bounce height decaying on every floor hit, so the stacked count reads as progress. It reads well on game resource-loading screens and onboarding wait screens, where a physical stack feels more concrete than a bar. Press R on the tile to restart the drop from zero.

Loader a Gravity Drop-height Value Stacks
Open in a new tab

03 · Loader a Gravity Drop-height Value Stacks — Measured area 6.757% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

DAMPINGGRAVITYarcTo
  for (var i = 0; i < BLOCKS; i++) {
    var b = blocks[i];
    if (b.state === 'waiting') {
      if (t >= i * DROP_INTERVAL) { b.state = 'falling'; b.y = -blockH; b.vy = 0; }
    }
    if (b.state === 'falling') {
      b.vy += GRAVITY * dt;
      b.y += b.vy * dt;
      var floorY = (H - margin) - (i + 1) * blockH;
      if (b.y >= floorY) {
        b.y = floorY;
        b.vy = -b.vy * DAMPING;
        if (Math.abs(b.vy) < REST_V) { b.state = 'settled'; b.vy = 0; }
      }
    }
    if (b.state === 'settled') settled++;
  }

04Loader a Seed-count Value Expands into Voronoi Cells

The seed-point count grows in proportion to progress, recomputing the Voronoi diagram every frame so the loader fills into a finished mosaic as the cells pack tighter. Portfolio page loads and image-gallery preloaders both suit this one, since a finished mosaic reads as content-ready rather than merely loaded. Click the tile to rerun the seed growth from an empty grid.

Loader a Seed-count Value Expands into Voronoi Cells
Open in a new tab

04 · Loader a Seed-count Value Expands into Voronoi Cells — Measured area 37.312% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

MAXSEEDSwobbleRsetTransform
  for (var i = 0; i < MAXSEEDS; i++) {
    seeds[i].px = seeds[i].hx0 + Math.sin(sec * seeds[i].wf + seeds[i].wp) * wobbleR;
    seeds[i].py = seeds[i].hy0 + Math.cos(sec * seeds[i].wf * 0.86 + seeds[i].wp) * wobbleR;
  }

  ctx.fillStyle = PAPER;
  ctx.fillRect(0, 0, W, H);

  for (var r = 0; r < rows; r++) {
    var cy = r * CELL + CELL / 2;
    for (var c = 0; c < cols; c++) {
      var cx = c * CELL + CELL / 2;
      var best = 0, bestD = Infinity;
      for (var s = 0; s < count; s++) {
        var dx = cx - seeds[s].px, dy = cy - seeds[s].py;
        var d = dx * dx + dy * dy;
        if (d < bestD) { bestD = d; best = s; }
      }
      ctx.fillStyle = PALETTE[best % PALETTE.length];
      ctx.globalAlpha = 0.55;
      ctx.fillRect(c * CELL, r * CELL, CELL - 1, CELL - 1);
    }
  }

05Loader a Shrinking Orbit-radius Value Pulls Along Field Lines

Progress is converted into an orbit-radius value so particles trace circles that pull inward along field lines toward the center, and the loader finishes as the radius nears zero. It fits card-style loading spinners and header loading indicators, where the field lines read as an active pull rather than a passive wait. Tab to the tile and press Enter to watch the radius close in again.

Loader a Shrinking Orbit-radius Value Pulls Along Field Lines
Open in a new tab

05 · Loader a Shrinking Orbit-radius Value Pulls Along Field Lines — Measured area 11.671% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

createRadialGradientPAPER_FADEshrink
  var t = sec % LOOP;
  var p = t / LOOP;                       // 0→1 progress
  var shrink = 1 - p;                     // orbit-radius multiplier — exactly 0 at p=1 (pull finished)

  ctx.fillStyle = PAPER_FADE;
  ctx.fillRect(0, 0, W, H);

  ctx.strokeStyle = LINE;
  ctx.lineWidth = 1.6;
  for (var i = 0; i < N; i++) {
    var q = parts[i];
    var rad = fieldR * q.r0 * shrink;
    var ang = q.phase + sec * q.speed;
    var x = cxp + Math.cos(ang) * rad;
    var y = cyp + Math.sin(ang) * rad;
    ctx.beginPath();
    ctx.moveTo(cxp, cyp);
    ctx.lineTo(x, y);
    ctx.stroke();
    ctx.fillStyle = PALETTE[i % PALETTE.length];
    ctx.beginPath();
    ctx.arc(x, y, 6.2, 0, Math.PI * 2);
    ctx.fill();
  }

06Loader an Erosion-thickness Value Eats into a Noise Ring

The inner radius of a ring drawn over a 2D noise field is driven by an erosion-thickness value tied to progress, so the noise eats jaggedly inward and the ring thins. Search-results wait screens and long-form submit loaders both suit the jagged erosion, since it reads as active work rather than a smooth fake. Resize the window to see the ring re-erode at the new size.

Loader an Erosion-thickness Value Eats into a Noise Ring
Open in a new tab

06 · Loader an Erosion-thickness Value Eats into a Noise Ring — Measured area 8.508% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

LATTICE_NfieldNoiseevenodd
function smooth(f) { return f * f * (3 - 2 * f); }
function valueNoise(x) {
  var xi = ((x % LATTICE_N) + LATTICE_N) % LATTICE_N;
  var i0 = Math.floor(xi);
  var i1 = (i0 + 1) % LATTICE_N;
  var f = smooth(xi - i0);
  return lattice[i0] * (1 - f) + lattice[i1] * f;
}
function fieldNoise(theta, sec) {
  var x1 = (theta / (Math.PI * 2)) * LATTICE_N + sec * 1.6;
  var x2 = (theta / (Math.PI * 2)) * LATTICE_N * 2.3 + 40 - sec * 2.4;
  return valueNoise(x1) * 0.68 + valueNoise(x2) * 0.32;
}

07Loader a Ripple-radius Value Spreads Across a Spring Mesh

Grid points linked by springs are rippled outward using a radius value driven by progress, and only the points the radius has passed pop up, leaving a wave across the mesh. Hero-section preloaders and interactive landing wait screens both fit this one well, since the ripple gives a page's first frame some motion before real content paints. Click the tile to send another ripple out from center.

Loader a Ripple-radius Value Spreads Across a Spring Mesh
Open in a new tab

07 · Loader a Ripple-radius Value Spreads Across a Spring Mesh — Measured area 22.819% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

STIFFNESSDAMPripple
function ripple(dt) {
  if (dt < 0) return 0;
  return AMP * Math.exp(-DAMP * dt) * Math.cos(STIFFNESS * dt);
}

function draw(sec) {
  var t = sec % LOOP;
  var p = t / LOOP;
  var frontR = p * maxR;
  var speed = maxR / LOOP;

  ctx.fillStyle = PAPER;
  ctx.fillRect(0, 0, W, H);

  ctx.strokeStyle = RING;
  ctx.lineWidth = 1.4;
  ctx.beginPath();
  ctx.arc(cxp, cyp, Math.max(2, frontR), 0, Math.PI * 2);
  ctx.stroke();

  var disp = new Array(pts.length);
  for (var i = 0; i < pts.length; i++) {
    var q = pts[i];
    var dt = (t - q.dist / speed);
    disp[i] = ripple(dt);

08Loader an Arc-sweep Angle Value Sweeps Satellites Around

Progress is converted into an angle value between 0 and 360 degrees so satellite dots trace their orbit only up to that angle, leaving a trailing streak that reads directly as progress. It suits payment-processing wait screens and API response wait badges, where a sweeping angle reads as steady progress rather than an indefinite spin. Press Space on the tile to pause the sweep mid-arc.

Loader an Arc-sweep Angle Value Sweeps Satellites Around
Open in a new tab

08 · Loader an Arc-sweep Angle Value Sweeps Satellites Around — Measured area 19.327% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

WIDE_SEGglobalAlphaRINGS
function draw(sec) {
  var t = sec % LOOP;
  var p = t / LOOP;
  var sweep = p * Math.PI * 2;         // 0→2π — global sweep angle

  ctx.fillStyle = PAPER;
  ctx.fillRect(0, 0, W, H);

  var WIDE_SEG = 30, WIDE_SPAN = 1.7;
  for (var w = WIDE_SEG; w >= 0; w--) {
    var wa0 = sweep - WIDE_SPAN * (w / WIDE_SEG);
    var wa1 = sweep - WIDE_SPAN * ((w - 1) / WIDE_SEG);
    var walpha = (1 - w / WIDE_SEG);
    ctx.fillStyle = S1;
    ctx.globalAlpha = walpha * walpha * 0.14;
    ctx.beginPath();
    ctx.moveTo(cxp, cyp);
    ctx.arc(cxp, cyp, baseR, wa0, wa1);
    ctx.closePath();
    ctx.fill();
  }
  ctx.globalAlpha = 1;

09Loader a Survival-rule Value Fills via Cellular Automaton

A survival rule is applied to every grid cell, computing the share of lit cells each generation and advancing generations until that share reaches the progress target, filling the loader. Bulk data-processing screens and background-sync loaders both suit it, since each generation reads as one more batch finishing rather than a single continuous bar. Click the tile to reseed the grid and watch the rule fill it again.

Loader a Survival-rule Value Fills via Cellular Automaton
Open in a new tab

09 · Loader a Survival-rule Value Fills via Cellular Automaton — Measured area 33.618% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

BIRTHneighborCountstepGen
  var c = 0;
  for (var dy = -1; dy <= 1; dy++) {
    for (var dx = -1; dx <= 1; dx++) {
      if (dx === 0 && dy === 0) continue;
      var nx = x + dx, ny = y + dy;
      if (nx < 0 || ny < 0 || nx >= GX || ny >= GY) continue;
      if (g[ny * GX + nx]) c++;
    }
  }
  return c;
}
function stepGen(prev) {
  var next = new Uint8Array(TOTAL);
  for (var y = 0; y < GY; y++) {
    for (var x = 0; x < GX; x++) {
      var idx = y * GX + x;
      if (prev[idx]) { next[idx] = 1; continue; }
      next[idx] = neighborCount(prev, x, y) >= BIRTH ? 1 : 0;
    }
  }
  return next;
}

Where These Loaders Break: the DAMPING and STIFFNESS Trap

The trap is treating DAMPING, STIFFNESS, or FREQ as free dials with no floor. Item 03's bounce already runs on DAMPING at 0.92: push that value closer to 1 and b.vy loses less speed on every floor hit, so it takes longer for Math.abs(b.vy) to drop under REST_V, and the block state takes longer to flip from falling to settled — the stack that was supposed to finish quickly starts to hang instead. Item 07's spring mesh has the mirror problem: ripple() decays by AMP * Math.exp(-DAMP * dt) while it also oscillates by Math.cos(STIFFNESS * dt), so lowering STIFFNESS without also raising DAMP lets the cosine term keep ringing well past the point the amplitude term should have flattened it, and a fresh wave front can start before the old one has visibly died out. Item 01's FREQ has a quieter version of the same trap: it drives Math.sin(x * FREQ + sec * SPEED1) against a fixed 4px x-step in the draw loop, so pushing FREQ far enough starts to undersample that sine and the surface reads as static rather than water. None of these are runtime errors — the computation keeps running every frame, it just stops reading as the loader it was built to be.

Three Constants to Change First, and What reduced-motion Removes

Variant Changed value What it should feel like
Adjust the wave-frequency value 01 FREQ 0.045 → 0.09 Raising the frequency value should make the liquid surface ripple in smaller, faster waves.
Adjust the bounce-damping value 03 DAMPING 0.92 → 0.6 Lowering the damping value should make each block bounce lower and settle sooner after hitting the floor.
Adjust the spring-stiffness value 07 STIFFNESS 9 → 16 Increasing the stiffness value should snap the mesh points back to place faster, making the ripple shorter.

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 at all, and a single still frame draws instead at a fixed progress value rather than mid-loop. Every clickable 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 5ert9w9q in the same folder as this article's demo — open one before pushing DAMPING or STIFFNESS toward the trap above, since a stack that never settles or a mesh that never quiets reads fine in the table above but can hang a real loading state indefinitely.

Where You'd Actually Reach for One of These Nine

Item 01's rising water level is the one to reach for on a file-upload progress modal or an app splash screen — it only needs the one progress number a real upload already reports, and the two sine layers give an otherwise flat bar some texture without adding a second data source.

Item 05's shrinking orbit radius and item 08's sweeping arc both suit smaller, more circular slots — a card-style spinner or a payment-processing wait badge — where a straight bar wouldn't fit the shape of the space at all; the choice between them mostly comes down to whether particles pulling inward or dots orbiting a fixed radius fits the surrounding UI's other motion better.

Item 09's cellular-automaton fill is worth reaching for on a bulk data-processing screen or a background-sync loader, since each generation reading as one more batch landing maps onto real batch work far more honestly than a percentage a UI thread just made up.

Where Can You Find css Loading Animation Examples Like These?

A css loading animation generator can hand you a working keyframe in seconds, which is a fine start for a first draft, but a generator built around CSS keyframes has no particle array to converge, no grid of cells to run a survival rule over, and no spring mesh to ripple — item 02's arrival ratio and item 09's generation-by-generation fill only exist because a plain JavaScript number drives the canvas every frame, not because a property eased from one value to another. If you're after a css loading animation template you can drop straight into a project, each of the nine below is exactly that: open the source under its pattern, copy the file, and swap in the progress source your own upload or request actually reports.

Questions people ask before wiring up a canvas loader

Can I use this as a css loading animation template for a file upload?

Yes — item 01 is built exactly for that: feed the real upload's progress ratio into level(p) in place of the demo's looping p, and the two sine layers keep shaking the surface as the number climbs toward 1.

Why does item 03's stack look different from item 09's fill?

Item 03 tracks a fixed, small number of falling blocks and stops the moment the last one settles, while item 09 recomputes a full grid of cells every generation and keeps going until the lit-cell ratio crosses the target — one is a countable stack, the other is a percentage read off a whole field.

Where would I actually find a css loading animation codepen for one of these nine?

You wouldn't need to — every one of the nine is printed in full under its own pattern above, pulled straight from this article's own demo source rather than linked out to somebody else's pen.

Sources: MDN — Canvas API · MDN — CanvasRenderingContext2D · MDN — requestAnimationFrame · MDN — prefers-reduced-motion · MDN — Using the progressbar role

Related reading: 9 Metaball Effect Scenes, Marching Squares Canvas · CSS Background Pattern Generator: 9 Seeded Scenes · 9 CSS AI Chat Loading Animations to Copy-Paste — the first computes a different physics field (a metaball threshold) over the same library-free 2D canvas these nine loaders use, the second seeds its own generative layouts the same way item 02 and item 04 seed their particle and wobble values, and the third is another nine-pattern loading set, built around chat-specific motion instead of the physics computation these nine run on.

Enter the archive password

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