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

CSS Download Button: 9 Demos With Springs and Arcs

A css download button that only swaps its label leaves a stiff arrow, steps in progress and no sign of where the file went — scenes 01, 02 and 03 compute each.

See All Nine Download Button Scenes Running on This Page

The grid below follows one download from the press to the failure. While nobody touches a cell it replays a short scripted run every two seconds, and the first pointer move or key press stops that script and hands the same calculation to you. No file is fetched anywhere, so what you watch is only how a button can move.

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

CSS Download Button: 9 Demos Where Spring, Curve and Path Are Computed

The nine scenes are ordered the way a download unfolds. Scene 01 is the press, where the arrow sinks and springs back, and 02 fills a ring along a curve instead of a straight line. Scene 03 sends the file into a tray, 04 counts the received size up, and 05 raises a liquid level. Scene 06 turns the arrow into a check, 07 lets you pick a format from a split button, 08 shakes and retries after a failure, and 09 leans toward the cursor with a sheen.

Where These Buttons Fit

Any page that hands out a file with one click can use them, such as a resource library or a product page with a brochure. A page that serves large files needs the progress of 02 and 04 first, while a page where people collect several downloads wants the tray of 03. A document that exists in more than one format wants the split button of 07. In every case the state of the download is reduced to one number, and the shape on screen is calculated from that number.

How This Set Differs From Neighboring Posts

9 button state css patterns, loading to done covers how a button changes after the press, and Download Progress Bar: 9 Loading UI Patterns covers a list where files pile up while they download. This set hangs all nine scenes on one button and asks which calculation moves it through press, progress, arrival, completion and failure.

Code for Each Download Button Technique

Each scene is built on a native <button>, and the motion is painted from values that JS recomputes every frame in a requestAnimationFrame loop, so no CSS transition or keyframe is involved. If you were looking for download button css code to copy, the snippets are the exact source lines that do the moving, taken as one unbroken stretch from each scene and with the on-screen words switched to English. No library or CDN is loaded anywhere.

01An Arrow That Sinks and Springs Back

Pressing sinks the arrow by SINK_PX 10 and tilts it up to 14 degrees toward the horizontal spot where the press landed, and both values come from integrating a spring equation in steps of 8 ms or less. With SPRING_K 120 and SPRING_DAMP 9 the damping ratio is about 0.41, so the arrow passes its rest point by roughly 24% of the sink depth before settling. In the render it reached 13.3% cumulative area at a mean intensity of 72.3 and moved in 23/23 frames.

An Arrow That Sinks and Springs Back
Open in a new tab

01 · An Arrow That Sinks and Springs Back — Measured area 13.29% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

SINK_PXSPRING_KSPRING_DAMP
  function integrate(dt, g) {
    var n = Math.ceil(dt / 0.008);
    var h = dt / n;
    for (var i = 0; i < n; i += 1) {
      vy += (-SPRING_K * (y - g.depth) - SPRING_DAMP * vy) * h;
      y += vy * h;
      vt += (-SPRING_K * (tilt - g.lean * TILT_MAX) - SPRING_DAMP * vt) * h;
      tilt += vt * h;
    }
  }

  function render() {
    var p = clamp(y / SINK_PX, -0.3, 1);
    icon.style.transform = 'translateY(' + y.toFixed(2) + 'px) rotate(' + tilt.toFixed(2) + 'deg)';
    btn.style.transform = 'translateY(' + (y * 0.3).toFixed(2) + 'px)';
    btn.style.setProperty('--p', p.toFixed(3));
    yEl.textContent = y.toFixed(1);
    tiltEl.textContent = tilt.toFixed(1);
  }

02A Border Ring Filled by a Progress Curve

A thick ring follows ringCurve(u) = 1 - (1 - u)^CURVE_POW while a thin inner ring follows the even progress u, and both are drawn by moving stroke-dashoffset on a path whose pathLength is 100. With CURVE_POW 2 the thick ring leads the thin one by at most 25 points, at u 0.5, and it never falls behind. Here the ring covered 11.1% cumulative area at an intensity of 117.5 and moved in 19/23 frames.

A Border Ring Filled by a Progress Curve
Open in a new tab

02 · A Border Ring Filled by a Progress Curve — Measured area 11.07% · moving frames 19/23Original by GODRICH · SCSS and React ship in the zip · See the password

CURVE_POWRING_RRUN_MS
  function render(now) {
    var s = startOf(now);
    var u = s === null ? 0 : Math.max(0, Math.min(1, (now - s) / RUN_MS));
    if (reduce && runStart === 'done') u = 1;
    var p = ringCurve(u);
    var pct = Math.round(p * 100);
    ring.style.strokeDashoffset = String((100 - p * 100).toFixed(2));
    ghost.style.strokeDashoffset = String((100 - u * 100).toFixed(2));
    ring.style.opacity = p < 0.004 ? '0' : '1';
    ghost.style.opacity = u < 0.004 ? '0' : '1';
    btn.style.setProperty('--f', p.toFixed(3));
    pctEl.textContent = pct + '%';
    leadEl.textContent = String(Math.round((p - u) * 100));
    labelEl.textContent = u >= 1 ? 'Done' : (u > 0 ? 'Receiving' : 'Download');
    bar.setAttribute('aria-valuenow', String(pct));
    bar.setAttribute('aria-valuetext', pct + '%');
  }

03A File That Flies Into the Tray

The file icon follows a quadratic Bezier path over FLY_MS 700, bending by at most ARC_HEIGHT 56 px, while it turns up to 28 degrees and shrinks from full size to 0.55. On arrival the tray is pushed down by TRAY_BOUNCE 14 px and a spring with a damping ratio of about 0.37 brings it back, so it makes several decaying bounces rather than one. This scene measured 11.8% cumulative area, intensity 101.2 and 21/23 moving frames.

A File That Flies Into the Tray
Open in a new tab

03 · A File That Flies Into the Tray — Measured area 11.791% · moving frames 21/23Original by GODRICH · SCSS and React ship in the zip · See the password

FLY_MSARC_HEIGHTTRAY_BOUNCE
  function renderFile(now) {
    if (flightStart === null) { file.style.opacity = '0'; return; }
    var t = Math.min(1, (now - flightStart) / FLY_MS);
    var e = ease(t);
    var g = geometry();
    var cx = g.x + 2 * ARC_HEIGHT;
    var cy = (g.y0 + g.y2) / 2;
    var x = (1 - e) * (1 - e) * g.x + 2 * (1 - e) * e * cx + e * e * g.x;
    var y = (1 - e) * (1 - e) * g.y0 + 2 * (1 - e) * e * cy + e * e * g.y2;
    var rot = 28 * Math.sin(Math.PI * e);
    var sc = 1 - 0.45 * e;
    file.style.opacity = t > 0.9 ? String((1 - t) * 10) : '1';
    file.style.transform = 'translate(' + (x - 14).toFixed(1) + 'px,' + (y - 14).toFixed(1) + 'px) rotate(' + rot.toFixed(1) + 'deg) scale(' + sc.toFixed(3) + ')';
  }

  function renderTray() {
    tray.style.transform = 'translateY(' + ty.toFixed(2) + 'px)';
    tray.style.setProperty('--h', Math.max(0, Math.min(1, ty / TRAY_BOUNCE)).toFixed(3));
    countEl.textContent = String(count);
  }

04A Chip That Counts Up the Received Size

The received size is TARGET_MB x (1 - (1 - u)^COUNT_EASE), so the number climbs fast and slows near the end, and the time left is the remaining amount divided by the average speed so far. The 48 MB and 2 minutes are constants chosen for the demo, not a measured network. It scored 18.6% cumulative area at an intensity of 80.9 and moved in 19/23 frames.

A Chip That Counts Up the Received Size
Open in a new tab

04 · A Chip That Counts Up the Received Size — Measured area 18.608% · moving frames 19/23Original by GODRICH · SCSS and React ship in the zip · See the password

COUNT_EASETARGET_MBETA_MIN
  function render(now) {
    var s = startOf(now);
    var u = s === null ? 0 : Math.max(0, Math.min(1, (now - s) / RUN_MS));
    if (reduce && finished) u = 1;
    var got = TARGET_MB * (1 - Math.pow(1 - u, COUNT_EASE));
    var eta = '--:--';
    if (u > 0) {
      var elapsedSec = Math.max(u, 0.004) * ETA_MIN * 60;
      var avg = Math.max(got, 0.01) / elapsedSec;
      var left = Math.round((TARGET_MB - got) / avg);
      eta = Math.floor(left / 60) + ':' + two(left % 60);
    }
    root.style.setProperty('--f', (got / TARGET_MB).toFixed(3));
    gotEl.textContent = got.toFixed(1);
    etaEl.textContent = eta;
    labelEl.textContent = u >= 1 ? 'Done' : (u > 0 ? 'Receiving' : 'Download');
    if (u >= 1 && !announced) { announced = true; statusEl.textContent = 'Download complete'; }
    if (u < 1 && announced) { announced = false; statusEl.textContent = ''; }
  }

05A Liquid Level That Rises With Progress

The surface is a sum of two sine waves sampled across 48 segments (49 points), and its amplitude shrinks as amp = WAVE_AMP (1 - 0.7 level), so the water calms as it fills. The level itself chases the target progress with an exponential follow of LEVEL_LERP 6 per second instead of jumping. Its numbers were 12.0% cumulative area, intensity 130.8 and 23/23 moving frames.

A Liquid Level That Rises With Progress
Open in a new tab

05 · A Liquid Level That Rises With Progress — Measured area 12.04% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

WAVE_AMPWAVE_FREQLEVEL_LERP
  function draw(phase) {
    var reach = WAVE_AMP * 1.5;
    var base = -reach + (H + 2 * reach) * (1 - level);
    var amp = WAVE_AMP * (1 - 0.7 * level);
    var d = 'M0 ' + H;
    for (var i = 0; i <= POINTS; i += 1) {
      var x = (i / POINTS) * W;
      var a = 2 * Math.PI * WAVE_FREQ * (x / W);
      var y = base + amp * (Math.sin(a + phase) + 0.5 * Math.sin(2 * a - 1.3 * phase));
      d += 'L' + x.toFixed(1) + ' ' + y.toFixed(2);
    }
    d += 'L' + W + ' ' + H + 'Z';
    pathEl.setAttribute('d', d);
    var pct = Math.round(level * 100);
    pctEl.textContent = pct + '%';
    labelEl.textContent = level > 0.995 ? 'Done' : (level > 0.01 ? 'Receiving' : 'Download');
    levelEl.textContent = level.toFixed(2);
    ampEl.textContent = amp.toFixed(1);
  }

06A Finish Where the Arrow Becomes a Check by Stroke Length

One progress value p shortens the arrow stroke over its first half and draws the check stroke from p 0.4 onward, so the two overlap briefly between 0.4 and 0.5. The button width then settles to the measured label length at WIDTH_LERP 10 per second, so a longer label makes a wider button. The render gave 11.6% cumulative area, intensity 50.8 and 18/23 moving frames.

A Finish Where the Arrow Becomes a Check by Stroke Length
Open in a new tab

06 · A Finish Where the Arrow Becomes a Check by Stroke Length — Measured area 11.569% · moving frames 18/23Original by GODRICH · SCSS and React ship in the zip · See the password

STROKE_LENCHECK_MSWIDTH_LERP
  function render(want) {
    var shrink = clamp(p / 0.5, 0, 1);
    var draw = clamp((p - 0.4) / 0.6, 0, 1);
    arrow.style.strokeDashoffset = String((STROKE_LEN * shrink).toFixed(2));
    check.style.strokeDashoffset = String((STROKE_LEN * (1 - draw)).toFixed(2));
    label.textContent = p >= 0.5 ? 'Done' : 'Download';
    label.style.opacity = String(Math.abs(2 * p - 1).toFixed(2));
    btn.style.setProperty('--p', p.toFixed(3));
    btn.style.width = width.toFixed(1) + 'px';
    pEl.textContent = p.toFixed(2);
    wEl.textContent = String(Math.round(width));
    if (p >= 1 && !announced) { announced = true; statusEl.textContent = 'Download complete'; }
    if (p <= 0 && announced) { announced = false; statusEl.textContent = ''; }
  }

07A Split Button That Picks the Format

Opening the arrow side unfolds the three formats one after another, each starting MENU_STAGGER 70 ms later than the last with an overshooting ease. When you choose one, the old label rises 24 px out of a 24 px window while the new one comes up from below. Measured, it reached 17.7% cumulative area at an intensity of 90.6 and moved in 19/23 frames.

A Split Button That Picks the Format
Open in a new tab

07 · A Split Button That Picks the Format — Measured area 17.706% · moving frames 19/23Original by GODRICH · SCSS and React ship in the zip · See the password

MENU_STAGGERFORMATSLABEL_RISE
  function render() {
    var s = open * OPEN_MS;
    var visible = open > 0.001 || wantOpen;
    for (var i = 0; i < items.length; i += 1) {
      var q = outBack(clamp((s - i * MENU_STAGGER) / ITEM_MS, 0, 1));
      var o = clamp(q, 0, 1);
      items[i].style.opacity = o.toFixed(3);
      items[i].style.transform = 'translateY(' + ((1 - q) * -16).toFixed(2) + 'px) scale(' + (0.8 + 0.2 * q).toFixed(3) + ')';
      items[i].style.visibility = visible ? 'visible' : 'hidden';
      items[i].setAttribute('tabindex', wantOpen ? '0' : '-1');
      items[i].classList.toggle('is-picked', i === wantFmt);
    }
    menu.setAttribute('aria-hidden', wantOpen ? 'false' : 'true');
    toggle.setAttribute('aria-expanded', wantOpen ? 'true' : 'false');
    toggle.style.setProperty('--o', open.toFixed(3));
    root.style.setProperty('--k', (1 - rise).toFixed(3));
    oldEl.style.transform = 'translateY(' + (-LABEL_RISE * outCubic(rise)).toFixed(2) + 'px)';
    newEl.style.transform = 'translateY(' + (LABEL_RISE * (1 - outBack(rise))).toFixed(2) + 'px)';
    oldEl.style.opacity = String((1 - rise).toFixed(2));
    textEl.textContent = FORMATS[wantFmt];
  }

08A Button That Fails, Decays and Retries

A shake of SHAKE_AMP 14 px with SHAKE_DECAY 5 per second and SHAKE_HZ 4.5 sways the whole button sideways, and then the ring rewinds to zero over REWIND_MS 500 while the label turns into a retry prompt. The first real attempt fails and the second finishes, because the code decides that for the demo, not a network. Its figures were 10.9% cumulative area, intensity 135.5 and 23/23 moving frames.

A Button That Fails, Decays and Retries
Open in a new tab

08 · A Button That Fails, Decays and Retries — Measured area 10.896% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

SHAKE_AMPSHAKE_DECAYREWIND_MS
    var t = now - s;
    if (t < RUN_MS) {
      var u = outQuad(t / RUN_MS);
      return { p: res === 'fail' ? FAIL_AT * u : u, x: 0, e: 0, label: 'Receiving', glyph: ARROW, note: '' };
    }
    if (res === 'ok') return { p: 1, x: 0, e: 0, label: 'Done', glyph: ARROW, note: 'Download complete' };
    var tau = (t - RUN_MS) / 1000;
    var rw = clamp((t - RUN_MS - REWIND_DELAY) / REWIND_MS, 0, 1);
    return {
      p: FAIL_AT * (1 - inOut(rw)),
      x: SHAKE_AMP * Math.exp(-SHAKE_DECAY * tau) * Math.sin(2 * Math.PI * SHAKE_HZ * tau),
      e: 1,
      label: rw >= 1 ? 'Retry' : 'Failed',
      glyph: BANG,
      note: 'The download was interrupted'
    };

09A Button That Leans to the Cursor and Gleams

The button leans toward the cursor by the distance times PULL_RATE 0.12, capped at LEAN_MAX 10 px, and a sheen band 64 px wide and tilted 20 degrees follows the cursor's horizontal position. The pointer is tracked over a zone of up to 280 by 144 px around the button rather than over the button alone, so the lean starts before you touch it. The scene came to 18.2% cumulative area at an intensity of 82.3 and moved in 23/23 frames.

A Button That Leans to the Cursor and Gleams
Open in a new tab

09 · A Button That Leans to the Cursor and Gleams — Measured area 18.191% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

PULL_RATESHEEN_WLEAN_MAX
  function goal(now) {
    var w = btn.offsetWidth;
    var h = btn.offsetHeight;
    var d = null;
    if (root.classList.contains('is-demo') && now >= START_MS) {
      var a = 2 * Math.PI * ((now - START_MS) % CYCLE_MS) / CYCLE_MS;
      d = { x: ORBIT_X * Math.cos(a), y: ORBIT_Y * Math.sin(a) };
    } else if (cursor) {
      d = cursor;
    }
    if (kbFocus && !d) return { lx: 0, ly: 0, sx: w / 2, a: 1, gx: 50, gy: 50 };
    if (!d) return { lx: 0, ly: 0, sx: -SHEEN_W * 2, a: 0, gx: 50, gy: 50 };
    return {
      lx: clamp(d.x * PULL_RATE, -LEAN_MAX, LEAN_MAX),
      ly: clamp(d.y * PULL_RATE, -LEAN_MAX, LEAN_MAX),
      sx: clamp(d.x + w / 2, -SHEEN_W, w + SHEEN_W),
      a: 1,
      gx: clamp((d.x + w / 2) / w * 100, -20, 120),
      gy: clamp((d.y + h / 2) / h * 100, -40, 140)
    };
  }

Where the Values Change the Scene — Spring Strength, Curve Power, Amplitude

Three constants decide most of the character of these scenes: how much the press wobbles, how far progress runs ahead and how much the surface sloshes. When you tune css download button animation, changing one of these numbers shifts the scene faster than rewriting the formula. The figures below come from rendering each scene for two seconds, and movement is counted across the 23 gaps between the 24 captured frames.

Item Cumulative area % Mean intensity Moving frames
01 Sinking arrow 13.3% 72.3 23/23
02 Curve ring 11.1% 117.5 19/23
03 Flying file 11.8% 101.2 21/23
04 Size counter 18.6% 80.9 19/23
05 Liquid level 12.0% 130.8 23/23
06 Check stroke 11.6% 50.8 18/23
07 Format menu 17.7% 90.6 19/23
08 Failure shake 10.9% 135.5 23/23
09 Magnet sheen 18.2% 82.3 23/23

No scene stays under 10% cumulative area. Scenes 02, 03, 04, 06 and 07 moved in fewer than 23 frames, and the source was not checked for why, so only the measured counts are given.

Spring Strength Sets the Overshoot

The damping ratio in 01 is SPRING_DAMP 9 divided by twice the square root of SPRING_K, which is about 0.41 at 120 and an overshoot of roughly 2.4 px on a 10 px sink. Leave the damping alone and set SPRING_K to 240, and the ratio falls to about 0.29 while the overshoot grows to about 39% of the sink.

Curve Power Sets the Lead

The lead in 02 is the curve's percentage minus the even percentage, and at power 2 it peaks at 25 points when u is 0.5. At power 4 the peak moves to about u 0.37 and reaches about 47 points.

Amplitude Sets the Slosh

In 05 the amplitude at level 0.5 with WAVE_AMP 6 is about 3.9 px. At 12 it becomes about 7.8 px, and the calming as the level rises stays the same.

Where It Breaks — Backward Progress, Narrow Buttons, Keyboard Only

A Falling Target Drains the Liquid

Scenes 02 and 04 derive progress from time, so it never runs backward. Scene 05 follows a target, so if that target drops, the level drains and the percentage text falls with it, which is exactly what you see when the demo returns to zero at the end of its cycle. When you feed in a real value, first make sure it only ever grows.

A Long Label Gets Clipped

Scene 06 resizes the button to the measured label, but max-width and hidden overflow cap it, so a long English label can lose its end. Scene 07 gives its three format cells equal shares, which squeezes the letters once the width falls below 260 px.

Keyboard Use Loses Tilt and Pull

In 01 a key press sets the tilt target to zero, so the lean that normally comes from the pointer position is missing. Scene 09 does not pull the button on focus alone, and only the sheen settles at the center. Scene 07 hides closed cells with visibility: hidden and tabindex="-1" so focus cannot land in them.

Demo Values Are Not Real Downloads

The failure in 08 and the 48 MB over 2 minutes in 04 are fixed for the demo. Real speed is uneven, and a time left computed from the average speed is badly off near the start. Scene 03 re-reads the button and tray positions from offsetTop on every frame, but it keeps the horizontal position at half the box width, so a layout that sits off-center pulls the path sideways.

A download button css codepen is fine for a quick look, but the archive keeps all nine pages and their SCSS in one place so you can change the numbers offline. Download it and open it with the password f24sfs72, and each of the nine folders holds one HTML page and its stylesheet.

Three Variants and Accessibility

Item Constant Value What changes
01 SPRING_K 120 → 240 The ratio drops from about 0.41 to 0.29, so the overshoot grows from 24% to 39% of the sink and the wobble is easier to see.
02 CURVE_POW 2 → 4 The lead over an even pace grows from at most 25 points to about 47, and the end crawls for longer.
05 WAVE_AMP 6 → 12 Doubling the amplitude grows the surface wobble at half level from about 3.9 px to 7.8 px, so it reads as sloshing water.

Counting the sources of all nine scenes, <button>, prefers-reduced-motion and requestAnimationFrame each appear in 9 of 9 HTML files and :focus-visible in 9 of 9 stylesheets, so every scene can be reached with Tab and keeps an outline. Enter and Space work through the ordinary click, except in 01, which reads keydown and keyup separately, and 07 opens with ArrowDown, moves with the arrows and closes with Escape. Only 02 and 08 carry role="progressbar", and four scenes announce their result through role="status". Under reduce no loop starts and each input draws its end state directly: 03 only raises the count without flying a file, and 05 fills the level at once. The failure in 08 is also told in words rather than color alone.

Is Swapping the Label Enough for an HTML CSS Download Button?

For a small file that finishes after one click, yes. A label swap cannot show the wobble of the press, the lead of the progress or where the file went, so add the calculations only on screens with big files or repeated downloads.

When you need another reaction, the neighboring posts continue from here. The return of a press is in Button Press Animation UI: 9 Calculation Patterns, and keyframe-based buttons you can paste are in 9 CSS Button Animations (SCSS + React, copy-paste).

Frequently Asked Questions Before Building a CSS Download Button

Does computing everything in JS make a css download button heavy?

Each scene only works out a few numbers per frame and paints a handful of style values. The demo runs only while nobody is using the button, and under reduce no loop starts, so the cost of one button is small.

Can I build a tailwind css download button this way?

The code here is plain CSS and JS with no Tailwind. Because the motion comes from computed values and not from classes, the calculation stretches can be carried over even if you restyle the markup with utility classes.

How Do I Connect the Progress to a Real Value?

Replace the time in 02, 04 and 05 with the share of bytes received. Scene 05 follows a target downward as well, so check that your input only grows, and for 04 swap the average speed for a recent window once real speed turns uneven.

Enter the archive password

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