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

Button Click Effect CSS: 9 Canvas Pixel Patterns

Nine button click effect css demos. The button cracks like glass for one reason: a crack-angle value tied to a press-state flag.

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

One Canvas Loop, Nine Different Constants

Every one of these nine starts from the same harness: a <canvas> sized to the button, one requestAnimationFrame loop, and four listeners — click, keydown for Enter/Space, pointerdown for coordinates, and resize. What changes from pattern to pattern is only the single constant each demo's math is built around — a diffuse rate, a branch count, a jitter interval, a decay time — and none of the nine loads a library, a WebGL context, or a CDN script to do it.

01Ink-diffusion Button That Spreads From the Click Point

A click seeds one bright cell in a stored grid, then diffuseStep() runs every frame, averaging that value into each neighbor at DIFFUSE_RATE = 0.46 while DECAY_PER_FRAME = 0.978 fades the whole grid at the same time. render() reads the grid back into pixels each frame, so the bloom is the grid's own averaged state, not a CSS radial-gradient animating its size.

Ink-diffusion Button That Spreads From the Click Point
Open in a new tab

01 · Ink-diffusion Button That Spreads From the Click Point — Measured area 29.288% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

DIFFUSE_RATEinjectAtdiffuseStep
  function diffuseStep() {
    for (var y = 0; y < ROWS; y++) {
      for (var x = 0; x < COLS; x++) {
        var i = idx(x, y);
        var sum = 0, n = 0;
        if (x > 0) { sum += field[idx(x - 1, y)]; n++; }
        if (x < COLS - 1) { sum += field[idx(x + 1, y)]; n++; }
        if (y > 0) { sum += field[idx(x, y - 1)]; n++; }
        if (y < ROWS - 1) { sum += field[idx(x, y + 1)]; n++; }
        var avg = n ? sum / n : field[i];
        var v = field[i] + (avg - field[i]) * DIFFUSE_RATE;
        next[i] = v * DECAY_PER_FRAME;
      }
    }
    var tmp = field; field = next; next = tmp;
  }

02Crack-line Button That Splits With a Press-state Value

The moment a press-state flag flips on, buildTree() fires BTN_CANVAS_CRACK_BRANCH_COUNT (6 by default) calls into genBranch(), and each call recurses up to CRACK_MAX_DEPTH = 3 times, splitting by CRACK_ANGLE_SPREAD and shrinking by CRACK_LEN_DECAY each generation. Every branch segment carries its own t0/t1 timestamps, so the crack grows outward over a few hundred milliseconds instead of appearing all at once.

Crack-line Button That Splits With a Press-state Value
Open in a new tab

02 · Crack-line Button That Splits With a Press-state Value — Measured area 20.131% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

BTN_CANVAS_CRACK_BRANCH_COUNTgenBranchbuildTree
  function genBranch(x, y, angle, len, depth, t0, segs) {
    if (depth > CRACK_MAX_DEPTH || len < 5) return;
    var ang = angle + (rnd() - 0.5) * 0.35;
    var x2 = x + Math.cos(ang) * len;
    var y2 = y + Math.sin(ang) * len;
    var dur = 120 + len * 4;
    segs.push({ x1: x, y1: y, x2: x2, y2: y2, t0: t0, t1: t0 + dur, w: Math.max(1, 3 - depth) });
    var childT0 = t0 + dur * 0.6;
    var childA = [ang + CRACK_ANGLE_SPREAD * 0.5, ang - CRACK_ANGLE_SPREAD * 0.5];
    for (var i = 0; i < childA.length; i++) {
      genBranch(x2, y2, childA[i] + (rnd() - 0.5) * 0.3, len * CRACK_LEN_DECAY, depth + 1, childT0, segs);
    }
  }

  function buildTree(cx, cy) {
    var segs = [];
    for (var i = 0; i < BTN_CANVAS_CRACK_BRANCH_COUNT; i++) {
      var base = (Math.PI * 2 * i) / BTN_CANVAS_CRACK_BRANCH_COUNT;
      var ang = base + (rnd() - 0.5) * 0.5;
      genBranch(cx, cy, ang, CRACK_SEG_LEN, 0, 0, segs);
    }
    return segs;
  }

03Ember-trail Button Scattered by a Drag-speed Value

dragTo() turns the distance the pointer moved since the last frame into a speed value, and spawnEmbers() converts that speed into a particle count — Math.round(speed / 26), clamped between 1 and 9 — for every frame the pointer keeps moving. Each new ember gets its own outward velocity, then GRAVITY = 520 and AIR_DRAG = 0.988 pull and slow it until it fades.

Ember-trail Button Scattered by a Drag-speed Value
Open in a new tab

03 · Ember-trail Button Scattered by a Drag-speed Value — Measured area 28.606% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

GRAVITYspawnEmbersstepParticles
  function spawnEmbers(x, y, speed, now) {
    var count = Math.max(1, Math.min(9, Math.round(speed / 26)));
    for (var i = 0; i < count; i++) {
      var ang = rnd() * Math.PI * 2;
      var spd = 24 + rnd() * 96;
      particles.push({
        x: x, y: y,
        vx: Math.cos(ang) * spd * 0.34,
        vy: Math.sin(ang) * spd * 0.34 - 34 - rnd() * 46,
        born: now,
        life: EMBER_LIFE_MIN + rnd() * EMBER_LIFE_SPAN,
        size: 2.6 + rnd() * 3.2
      });
    }
  }

04Liquid-metal Button That Merges and Spreads With a Pointer-distance Value

Each of BLOB_COUNT = 7 drops keeps its own radius, and fieldAt() sums (effR * effR) / dist2 across all seven for any point on the canvas, boosted by POINTER_BOOST when the cursor sits close and by BURST_BOOST right after a click. Wherever that summed field crosses THRESH = 1.0, the drops are drawn as one merged surface instead of seven separate circles — a metaball field, not a blurred box-shadow.

Liquid-metal Button That Merges and Spreads With a Pointer-distance Value
Open in a new tab

04 · Liquid-metal Button That Merges and Spreads With a Pointer-distance Value — Measured area 51.592% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

fieldAtBLOB_COUNTbuildBlobs
  function fieldAt(px, py, now) {
    var burstT = now < burstUntil ? clamp01((burstUntil - now) / BURST_MS) : 0;
    var burstBoost = burstT * BURST_BOOST;
    var sum = 0;
    for (var i = 0; i < blobs.length; i++) {
      var b = blobs[i];
      var boost = burstBoost;
      if (pointer) {
        var dpx = pointer.x - b.x, dpy = pointer.y - b.y;
        var distToPointer = Math.sqrt(dpx * dpx + dpy * dpy);
        boost += POINTER_BOOST * Math.exp(-distToPointer / POINTER_FALLOFF);
      }
      var effR = b.r * (1 + boost);
      var dx = px - b.x, dy = py - b.y;
      var dist2 = dx * dx + dy * dy + 1;
      sum += (effR * effR) / dist2;
    }
    return sum;
  }

05Electric-arc Border Button That Thickens With a Hover-intensity Value

pathPoints() walks the button's rounded-rect edge, and every BTN_CANVAS_ARC_JITTER_MS (70ms by default) a fresh random offset lands on each of ARC_SEGMENTS = 6 anchors, so the line reads as arcing rather than smoothly easing. The same intensity value that widens the jitter also feeds CRACK_VALUE_ECHO into the line-width formula, so a hotter arc looks both shakier and thicker at once.

Electric-arc Border Button That Thickens With a Hover-intensity Value
Open in a new tab

05 · Electric-arc Border Button That Thickens With a Hover-intensity Value — Measured area 23.585% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

BTN_CANVAS_ARC_JITTER_MSARC_SEGMENTSstampGlow
    intensity += (targetIntensity - intensity) * Math.min(1, dt * 7);

    if (now - lastJitter >= BTN_CANVAS_ARC_JITTER_MS) {
      lastJitter = now;
      for (var i = 0; i < ARC_SEGMENTS; i++) {
        anchors[i] = (rnd() - 0.5) * 2 * JITTER_AMP_MAX * (0.12 + intensity);
      }
    }

06Button That Dissolves Into Smoke When a Disabled Value Turns On

The moment a disabled value turns on, the button's own pixels are pre-sampled into a shards[] array, and render() eases an assembly value toward 0 at ASSEMBLE_SPEED = 2.6, moving every shard along its own stored drift and a small upward lift. Turning the disabled value back off reverses the same assembly easing, so the shards reassemble into the original button instead of a new sprite fading in.

Button That Dissolves Into Smoke When a Disabled Value Turns On
Open in a new tab

06 · Button That Dissolves Into Smoke When a Disabled Value Turns On — Measured area 22.697% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

ASSEMBLE_SPEEDroundRecttoggle
  function render(dt) {
    assembly += (target - assembly) * Math.min(1, dt * ASSEMBLE_SPEED);
    ctx.clearRect(0, 0, W, H);
    for (var i = 0; i < shards.length; i++) {
      var s = shards[i];
      var local = Math.max(0, Math.min(1, (assembly - s.delay) / (1 - s.delay)));
      var scatter = 1 - local;
      var px = s.ox + s.dirX * scatter * scatter;
      var py = s.oy + s.dirY * scatter * scatter - scatter * 18;
      var alpha = local;
      if (alpha <= 0.01) continue;
      ctx.save();
      ctx.globalAlpha = alpha;
      ctx.translate(px + s.w / 2, py + s.h / 2);
      ctx.rotate(s.spin * scatter);
      ctx.fillStyle = 'rgb(' + s.r + ',' + s.g + ',' + s.b + ')';
      ctx.fillRect(-s.w / 2, -s.h / 2, s.w, s.h);
      ctx.restore();
    }
  }

07Progress Button That Piles Up Like Sand With a Progress Value

Every time the progress value climbs, spawnGrain() drops one grain at a random column, and updateGrains() pulls each grain down with GRAVITY = 560 until it reaches the current pile height and is removed from the active list. updatePile() eases each column's stored height toward targetFill * BH at EASE_RATE = 5.5, so the pile's outline settles a beat after the grains stop falling, not in the same frame.

Progress Button That Piles Up Like Sand With a Progress Value
Open in a new tab

07 · Progress Button That Piles Up Like Sand With a Progress Value — Measured area 21.685% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

hashNoisespawnGrainupdatePile
  function spawnGrain() {
    var col = Math.floor(rnd() * COLS);
    var size = 3 + rnd() * 3;
    grains.push({ x: BX + col * COL_W + COL_W / 2, y: SPAWN_Y, vy: 40 + rnd() * 30, size: size, col: col });
  }

  function updateGrains(dt) {
    if (grains.length > 90) grains.length = 90;
    for (var i = grains.length - 1; i >= 0; i--) {
      var g = grains[i];
      g.vy += GRAVITY * dt;
      g.y += g.vy * dt;
      var floorY = BY + BH - pileHeights[g.col];
      if (g.y + g.size / 2 >= floorY) grains.splice(i, 1);
    }
  }

  function updatePile(dt) {
    var targetHeight = targetFill * BH;
    for (var c = 0; c < COLS; c++) {
      var t = Math.max(0, Math.min(BH, targetHeight + colJitter[c]));
      pileHeights[c] += (t - pileHeights[c]) * Math.min(1, dt * EASE_RATE);
    }
  }

08Refraction-ripple Button That Displaces Pixels From the Press Point

Every pixel inside the ripple band gets getImageData's source pixel resampled from a shifted position instead of its own, where shiftMag = CRACK_VALUE_ECHO * ampDecay * envelope * Math.sin(phase) — the same 6 that item 02 uses as a branch count, here scaling how far a pixel gets pushed. BTN_CANVAS_RIPPLE_DECAY_MS (320ms by default) controls how fast ampDecay falls to zero, so a shorter value settles the wave in a frame or two instead of rippling out toward the edges.

Refraction-ripple Button That Displaces Pixels From the Press Point
Open in a new tab

08 · Refraction-ripple Button That Displaces Pixels From the Press Point — Measured area 17.142% · moving frames 20/23Original by GODRICH · SCSS and React ship in the zip · See the password

CRACK_VALUE_ECHOTEXTURE_CELLtoCanvasXY
      for (var y = minY; y <= maxY; y++) {
        for (var x = minX; x <= maxX; x++) {
          var dx = x - r.x, dy = y - r.y;
          var dist = Math.sqrt(dx * dx + dy * dy);
          var bandDist = dist - radius;
          if (bandDist > BAND_WIDTH || bandDist < -BAND_WIDTH) continue;
          var envelope = 1 - Math.abs(bandDist) / BAND_WIDTH;
          var phase = dist * FREQ - (elapsed / 1000) * ANG_SPEED;
          var shiftMag = CRACK_VALUE_ECHO * ampDecay * envelope * Math.sin(phase);
          var nx = dist > 0.0001 ? dx / dist : 0, ny = dist > 0.0001 ? dy / dist : 0;
          var sx = clampInt(Math.round(x + nx * shiftMag), 0, W - 1);
          var sy = clampInt(Math.round(y + ny * shiftMag), 0, H - 1);
          var srcIdx = (sy * W + sx) * 4, dstIdx = (y * W + x) * 4;
          outData[dstIdx] = baseData[srcIdx];
          outData[dstIdx + 1] = baseData[srcIdx + 1];
          outData[dstIdx + 2] = baseData[srcIdx + 2];
          outData[dstIdx + 3] = baseData[srcIdx + 3];
        }
      }

09Noise Button That Turns to Static When an Error Value Flips

trigger() just flips a boolean errorOn, and drawNoise() recomputes every cell in a NOISE_CELL = 3 grid from a per-pixel hash each TICK_MS = 40, so the static is a fresh deterministic hash per tick, not an animated opacity layer. Nothing here needs the seeded makeLcg() walk the other eight items use for their auto-demo coordinates, because a static toggle needs no click position at all.

Noise Button That Turns to Static When an Error Value Flips
Open in a new tab

09 · Noise Button That Turns to Static When an Error Value Flips — Measured area 22.181% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

BTN_CANVAS_STATIC_BURST_MSdrawNoiseTICK_MS
  function drawNoise(envelope, tick) {
    ctx.save();
    roundRect(ctx, BX, BY, BW, BH, 16);
    ctx.clip();
    ctx.globalAlpha = envelope;
    for (var y = BY; y < BY + BH; y += NOISE_CELL) {
      for (var x = BX; x < BX + BW; x += NOISE_CELL) {
        var n = hashNoise(x / NOISE_CELL, y / NOISE_CELL, SEED + tick);
        var v = Math.floor(n * 255);
        ctx.fillStyle = 'rgb(' + v + ',' + v + ',' + v + ')';
        ctx.fillRect(x, y, NOISE_CELL, NOISE_CELL);
      }
    }
    ctx.restore();
    ctx.globalAlpha = 1;
  }

Three Kinds of Value Are Deciding What You See

Group these nine by what their constant actually controls, and only three shapes show up.

A Recomputed Field Decides Shape — 01, 04, 08

01's ink grid, 04's metaball sum, and 08's pixel-shift field are all recomputed in full every frame from a stored array or loop — nothing here is a CSS property easing between two keyframes.

A Branch or Particle Count Decides Complexity — 02, 03, 07

02's branch count, 03's ember count, and 07's grain count each come from a value — a constant, a drag speed, a progress percentage — turned into how many things get drawn this frame, not how those things move.

A Timing Interval Decides Rhythm — 05, 06, 09

05's jitter interval, 06's assemble speed, and 09's tick interval each gate how often a value is allowed to change, which is what makes a jitter read as jitter instead of a smooth ease.

Where a Crack's Angle Value Ends Up Twice More

Item 02 declares BTN_CANVAS_CRACK_BRANCH_COUNT = 6 and loops that many times in buildTree() to decide how many crack branches leave the center. Item 05 and item 08 each declare their own separate constant, CRACK_VALUE_ECHO = 6, with a comment naming item 02 directly — item 05 multiplies it into the arc's lineWidth, item 08 multiplies it into the ripple's shiftMag inside getImageData. Nothing imports the value across files; three independent constants happen to hold the same 6, and each file's own comment is what ties them together, not a shared variable.

What Nine Frame-by-frame Pixel Counts Add Up To

Every number below comes straight from this run's 측정.json files, one per item, with moved frames counted out of 23 possible deltas between the 24 captured frames.

Item Cumulative area Avg. intensity Moved frames
01 Ink diffusion 29.3% 35.8 23/23
02 Crack branch 20.1% 64.7 23/23
03 Ember trail 28.6% 45.8 23/23
04 Liquid metal 51.6% 33.9 23/23
05 Electric arc 23.6% 54.0 23/23
06 Smoke dissolve 22.7% 57.2 23/23
07 Sand fill 21.7% 137.8 23/23
08 Ripple refraction 17.1% 37.4 20/23
09 Noise static 22.2% 61.5 23/23

04's merged metaball surface covers the most canvas area of the nine, and 08 is the only item that didn't move on all 23 of the 23 possible frames — more on that in the FAQ below.

Turn Three Constants, Feel Three Different Presses

Each of these three items ships with a second value in its source, ready to swap in for the default.

Item Value What moving it does
02 BTN_CANVAS_CRACK_BRANCH_COUNT 6 → 3 Lowering the value keeps the crack to one or two simple branches; raising it splits it into more branches, reading as a more shattered crack.
05 BTN_CANVAS_ARC_JITTER_MS 70 → 30 Shortening the value makes the arc line jitter fast frame to frame, reading as a harsh crackle; lengthening it widens the jitter interval, making the arc waver slowly instead.
08 BTN_CANVAS_RIPPLE_DECAY_MS 320 → 220 Shortening the value settles the ripple within a frame or two, leaving just a tap-like flicker; lengthening it lets the ripple spread longer, rippling out toward the edges more than once.

Where Each of These Nine Breaks First

None of these nine sets aria-pressed or any other ARIA state on its button — every one of them is a plain <button> wired to click, so a screen reader announces the label but never the crack, the fill, or the flipped error value; if you ship one of these for real feedback, add the state yourself.

None of the nine reads devicePixelRatio either, so the canvas backing store stays at its CSS pixel size; on a high-DPI screen every pixel-level effect here — the diffusion grid, the noise cells, the ripple's getImageData sampling — is computed at half the resolution the screen can actually show.

The resize listener on every one of these nine only calls render() again — none of them re-sets canvas.width/canvas.height on resize, so a layout that stretches the canvas element in CSS stretches a fixed-resolution buffer instead of redrawing it sharp.

The zip below carries all nine index.html files behind xbg2dm72, unpacked exactly as measured in the table above.

Where to Find More button click animation examples Than Just These Nine

If you're browsing past this set for more button click animation css inspiration, most galleries you'll find are CSS transitions on :active — border color, scale, shadow. A few authors publish a single button click effect codepen you can fork, but forking someone else's :active scale tells you nothing about how a getImageData ripple or a recursive crack tree gets built; that's the gap these nine are written to close.

Screens Where a Computed Break Actually Earns Its Keep

A crack that only appears once a press-state flag turns on (02) reads naturally on a delete-this-forever button — the shatter is the confirmation, not a separate toast. The same logic works in reverse for item 09: flip an error value on after a failed payment submit and the button itself turns to static for a beat, instead of a red border nobody notices on a small screen.

Item 07's sand-fill turns a plain progress percentage into a button that visibly fills while an upload or export runs, which reads better inline than a separate progress bar competing for the same row's attention. None of the nine needs a design-system change to adopt — the button keeps its real text; only the canvas layer underneath is doing anything new.

How This Set Differs From Button-press Springs, CTA Micro-interactions, and Canvas Scenes

Button Press Animation UI: 9 Calculation Patterns also hangs its nine demos on one value each, but that value is a press-depth spring returning through a CSS transform — no canvas, no per-pixel work, no getImageData anywhere in it.

CTA Button Design: 9 Interaction Patterns covers a wider set of click feedback — growing shadows, bouncing bars, sliding fills — built for conversion-focused buttons rather than for showing off one computation; several of its nine could ship without canvas at all.

HTML5 Canvas Animation: 9 Warp & Droplet Effects runs the same class of pixel math — droplet refraction, warp fields — but across a full scene canvas that isn't scoped to a button or triggered by a click; these nine instead keep the canvas sized to the button itself and wake up only on click, key, or drag.

What prefers-reduced-motion Switches Off Here

Every one of these nine checks window.matchMedia('(prefers-reduced-motion: reduce)') before starting its requestAnimationFrame loop; when it matches, the frame callback never gets a second call, and pressing the button still calls render() once directly, so the state change is still visible — just as a single drawn frame instead of an animation. Nothing about the underlying computation changes: the diffusion grid, the branch tree, the pixel shift all still run once; only the repeated requestAnimationFrame calls that would otherwise redraw them 20-plus times a second are skipped.

Questions Worth Answering Before You Unzip These Nine

Is there a button click effect codepen version of these nine, or only the zip?

No — these nine ship only as the password-protected zip and the live demo grid on this page; there's no separate embed to fork.

Do any of these nine need a library, WebGL, or a CDN script?

No. All nine are a single <canvas>, vanilla JS, and 2D context calls — no library, no WebGL context, no CDN script tag in any of the nine files.

Why does item 08 move on 20 of 23 frames instead of 23?

BTN_CANVAS_RIPPLE_DECAY_MS is 320ms by default, and ampDecay falls close enough to zero before the last few captured frames that the ripple's pixel shift drops under the measurement's movement threshold — the wave is still mathematically running, it's just too small to register as a moved pixel by then.

Enter the archive password

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