Design Lounge
Install

Nº 227 of 520

Download button with progress and retry

A download button that becomes a live progress bar with size, speed and time left, pause and resume, a drawn check on done, and resumable retry. Asset rows use rings.

Preview

Open full size
1280 × 800 · 19.1 KB · 295 lines

Use it

Free to use · credit appreciated

1 Once, in your project

npx skills add SusanAcharya/Design-Lounge

2 Then ask

Using the Design Lounge skill, build "Download button with progress and retry" (piece button-download-progress) in my stack. Match its brief; restyle it onto my product's theme.

No skill? Paste the brief instead. It carries the numbers, tokens and behaviour.

Brief

226 lines

Build brief for a coding agent. Rebuild this piece in the reader’s stack. If they haven’t said which stack, ask once, then default to semantic HTML + CSS + a little vanilla JS. Match the numbers below; don’t “improve” them.

What it is

The release download card for “pocketknife”, a fictional command-line task runner, on a near-black grid page. The hero is a 60px acid-lime button, “Download for macOS · arm64 · 18.4 MB”. Clicking it turns the same 60px row into a progress bar with a moving stripe. The bar reads “Downloading 41%” on the left and “7.5 / 18.4 MB · 3.3 MB/s · 4s left” on the right. Pause and cancel squares grow out of the right edge. Done fills the bar solid, draws a check and names the file. A dropped connection turns the bar coral and keeps the bytes, and the pause square becomes Retry, which resumes from where it stopped. Below, three asset rows use a 40px progress ring as their button: download, pause, resume, retry, done.

The detail worth copying is the label that inverts where the fill passes. The text is drawn twice, light on the track and dark inside the fill, and the fill’s clip-path reveals the dark copy. Every character stays readable at every percent.

Reference behaviour

  1. First frame: a 680px panel. Header: 36px lime mark, “pocketknife” (24px/700), “v3.2.0” in mono, and a “latest” outline tag. Lede: “A pocket-sized task runner for monorepos. Released 2 Oct 2026.”
  2. The hero button is idle: lime fill, a download glyph, “Download for macOS”, and “arm64 · 18.4 MB” right-aligned in mono at 75 % opacity. Below it, on the left, “sha256 7c1e4f…09a3b2” in mono. On the right, a switch “Simulate a dropped connection” (off).
  3. “Other assets”: linux-x64.tar.gz (17.9 MB) is already downloading from 38 % at about 2.2 MB/s, so the page is alive on load. windows-x64.zip (19.2 MB) is idle. checksums.txt (1.2 KB) is done, with a lime disc and a check.
  4. Click the hero: the button fades (160ms) and scales X to .97. The bar fades in under it. The pause and cancel squares grow from width 0 to 60px with an 8px left margin (280ms). Focus moves to Pause.
  5. While running, the fill width is the true fraction. The stripes (−45°, 8px dark at 9 % / 8px clear) slide continuously, 700ms per tile. The left label is “Downloading N%”. The right label is “got / total MB · speed MB/s · Ns left”. Speed wanders between 70 % and 130 % of 3.4 MB/s and is smoothed, so the number breathes rather than flickers.
  6. Pause: the stripes stop, the fill dims to #a9c447, the label reads “Paused at N%” and “got / 18.4 MB”, and the square shows a play glyph labelled “Resume download”. Resume continues from the same byte.
  7. Cancel: returns to the idle button at 0 and focuses it. The live region says “Download cancelled.”
  8. Done (18.4 of 18.4): the fill becomes solid lime with no stripes. A check draws (360ms after 120ms) before “Downloaded”, with the file name “pocketknife-3.2.0-macos-arm64.pkg” on the right. The squares collapse to 0. Under the bar, the checksum is replaced by a lime “Download again” text button, which takes focus.
  9. Error: with the switch on, the next download stops at exactly 47 %. The track turns rgba(255,106,77,.14) with a coral ring, and the fill turns coral with no stripes. The label reads ”(!) Connection dropped” and “8.6 / 18.4 MB kept”. The pause square becomes a coral Retry glyph and takes focus. Retry resumes from 8.6 MB. Each run fails at most once.
  10. Ring rows: click idle to start (the ring arc draws, the meta shows “got / total MB · speed MB/s”, the centre shows pause). Click to pause (the arc dims, “Paused · got / total MB”, play glyph). Click to resume. windows-x64.zip fails once at 62 % (the arc goes coral, the meta reads “Mirror reset at 62% · tap to retry”, retry glyph). Done: a lime disc scales in from 0 behind a drawn dark check, the meta reads “17.9 MB · verified”, and the button becomes aria-disabled.
  11. One requestAnimationFrame loop drives every running job and stops itself when nothing is running.

Structure

1280 × 800, panel 680px centred, 48px grid page with radial vignette
┌ panel ─────────────────────────────────────────────────────────┐ padding 28
│ [■] pocketknife v3.2.0 (latest)                                │
│ A pocket-sized task runner for monorepos. Released 2 Oct 2026. │
│ ┌ main (flex 1) ─────────────────────────────┐ ┌60┐ ┌60┐         │ 60px
│ │▓▓▓▓▓▓▓ Downloading 41% ░ 7.5 / 18.4 MB · …│ │ ‖│ │ ×│         │
│ └────────────────────────────────────────────┘ └──┘ └──┘         │
│ sha256 7c1e4f…09a3b2              (o) Simulate a dropped conn.  │
│ OTHER ASSETS                                                    │
│ [▤] pocketknife-3.2.0-linux-x64.tar.gz         ( ring 40 )      │ 12px rows
│     15.0 MB / 17.9 MB · 2.2 MB/s                                 │
│ [▤] pocketknife-3.2.0-windows-x64.zip          ( ring )         │
│ [▤] checksums.txt  1.2 KB · verified           ( disc + check ) │
└────────────────────────────────────────────────────────────────┘
  • .dl is a 60px flex row: .main (flex 1, position: relative) holds button.go and div.bar[role=progressbar], both inset: 0. Then button#pause.ctl and button#cancel.ctl.
  • .bar contains .txt (light label) and .fill (lime, clipped). .fill contains a second .txt with the same spans in dark ink. JS writes both copies.
  • .under is the checksum or “Download again”, plus the switch (<label> wrapping button[role=switch]).
  • <h2> then ul.rows > li.row: file icon (32px tile), .fname (mono name + mono meta), and button.ring. Inside the ring: .disc, an SVG with a track and arc circles (r=17, rotated −90°), and an icon SVG.
  • One visually hidden polite live region.

Tokens

:root {
  --bg: #0b0d0a;            /* page, 48px grid lines in --line, radial vignette to --bg at 70% */
  --surface: #121510;       /* panel */
  --surface-2: #1a1e17;     /* bar track, control squares, file tiles */
  --line: #262c22;
  --ink: #e4e9d8;
  --ink-2: #a3ac95;
  --ink-3: #7f8872;
  --acid: #c8f04a;          /* the one accent: button, fill, ring arc, done disc, focus */
  --acid-dim: #a9c447;      /* paused fill and arc */
  --on-acid: #10140a;
  --err: #ff6a4d;
  --err-soft: rgba(255, 106, 77, .14);

  --sans: "Epilogue", system-ui, sans-serif;
  --mono: "Fragment Mono", ui-monospace, monospace;

  --r: 10px;                /* button, bar, squares */
  --r-panel: 16px;
  --h: 60px;                /* hero row */
  --ring: 40px;             /* ring svg; button is 44px */
  --ring-c: 106.8;          /* 2π·17 */

  --t-micro: 160ms;
  --t-morph: 280ms;
  --t-check: 360ms;
  --t-stripe: 700ms;
  --ease: cubic-bezier(.2, .7, .2, 1);
  --ease-out: cubic-bezier(.16, 1, .3, 1);
}

Typography

RoleFamilySizeWeightLine-heightTrackingNotes
Product nameEpilogue24px7001−0.02em
LedeEpilogue15px5001.50--ink-2
Hero labelEpilogue16px70010--on-acid
Bar statusEpilogue15px60010“Downloading 41%”
Bar metaFragment Mono13px40010bytes · speed · eta, ellipsis
Version, tag, shaFragment Mono12–13px4001.50
Section labelEpilogue12px60010.1emUPPERCASE --ink-3
File nameFragment Mono13.5px4001.40ellipsis
File metaFragment Mono12.5px4001.40--ink-3, error --err
Switch labelEpilogue13px50010--ink-2

Numbers are mono so the bytes and speed don’t jitter sideways while ticking.

Motion

ElementTriggerPropertyFrom → ToDurationEasingReduced motion
.gostartopacity, scaleX1, 1 → 0, .97160 / 280ms--ease / --ease-out1ms
.barstartopacity0 → 1160ms--ease1ms
.ctl ×2start / endwidth, margin-left, opacity0, 0, 0 ↔ 60px, 8px, 1280ms--ease-out1ms
.fillprogressclip-path right inset100% → 0%per framen/a (data)kept, it is information
.fill stripesrunningbackground-position0 → 22.63px700ms looplinearstatic
hero checkdonestroke-dashoffset22 → 0360ms, delay 120ms--easedrawn
ring arcprogressstroke-dashoffset106.8 → 0per framen/akept
ring .discdonescale0 → 1280ms--ease-out1ms
ring checkdonestroke-dashoffset22 → 0360ms, delay 120ms--easedrawn
switch knobtoggletranslateX0 → 16px160ms--ease1ms
track/fill colourpause, errorbackground-colorlime → dim / coral280ms--ease1ms

The stripe is the only loop, and it runs only while bytes are moving. It stops on pause, error, done and reduced motion.

States

  • Idle: lime button. Hover brightness(1.06). Active scale .985.
  • Running: striped lime fill over the --surface-2 track with a 1px --line ring. Pause and cancel squares are visible.
  • Paused: fill #a9c447, stripes still. Play glyph, label “Resume download”.
  • Error: coral-tinted track and ring, coral fill without stripes, coral text on the track side and #1a0c08 inside the fill. Retry glyph in a coral-ringed square.
  • Done: solid lime, drawn check, file name. The squares collapse, and “Download again” replaces the checksum.
  • Ring idle / running / paused / error / done: download glyph / pause + lime arc / play + dim arc / retry + coral arc and coral meta / lime disc + dark check + aria-disabled.
  • Ring hover: the track goes #3a4234 (not when done).
  • Switch on: coral track, dark knob at 16px.
  • Focus-visible: 2px --acid outline, 3px offset, on every control including the squares and rings.

Accessibility

  • The hero idle button has a full label: “Download pocketknife for macOS, arm64, 18.4 megabytes”.
  • The bar is role="progressbar" with aria-valuemin=0, aria-valuemax=100, and aria-valuenow updated only when the integer percent changes. aria-valuetext is “41%, 7.5 MB of 18.4 MB”. It is aria-hidden while idle.
  • The squares are real buttons with labels that name the action: “Pause download”, “Resume download”, “Retry download”, “Cancel download”. They are inert when collapsed.
  • Focus: start → Pause. Error → Retry. Done → “Download again”. Cancel or again → the hero button. Focus never falls to <body> when a control collapses.
  • The live region announces: paused at N%, resumed, connection dropped at N% (retry resumes from X MB), download complete with file name and size, and each row’s downloaded or failed message. Progress ticks are not announced.
  • Ring labels: “Download linux-x64.tar.gz, 17.9 MB”, “Pause linux-x64.tar.gz”, “Resume …”, “Retry …”, and ”… downloaded” (aria-disabled="true", so it stays focusable and explains itself).
  • The switch is role="switch" with aria-checked and the visible label text.
  • Contrast: --ink-3 on --surface is 4.97:1. --on-acid on --acid is 14.2:1. --err on --surface is 6.5:1. Dark text on the coral fill is 6.75:1.
  • Hit targets: hero row 60px, squares 60 × 60, rings 44 × 44.

Responsive rules

  • ≥ 1280: 680px panel, as specified.
  • 1024–1279 and 768–1023: unchanged. The panel is min(680px, 100%).
  • ≤ 520: panel padding 20px. The hero’s “arm64 · 18.4 MB” is hidden. The bar hides its right-hand meta and keeps “Downloading N%”, because the squares need 136px. The checksum and switch stack. File names truncate with an ellipsis, so the ring never wraps.
  • The page grid must use grid-template-columns: minmax(0, 1fr). Otherwise long mono file names set the min-content width and push the page past 375px.

Acceptance checklist

Always

  • The idle button and the progress bar occupy the same row and height. The bar does not appear somewhere else.
  • The label stays legible across the fill edge (two layers, clipped).
  • Bytes, total, speed and time left are shown while running, in a monospace face.
  • Pause keeps the bytes. Resume continues from them.
  • Error keeps the bytes, explains what happened, and offers Retry in the same square as Pause.
  • Done names the file and offers a way to download again.
  • The stripe loop stops whenever bytes are not moving.
  • One rAF loop for all jobs, stopped when idle. No setInterval ticking.
  • Focus moves to the control that replaces the one that disappeared.

This demo

  • Hero file: macOS arm64, 18.4 MB, about 3.4 MB/s (finishes in about 5–6s).
  • Flaky switch: the next run fails at 47 % (“8.6 / 18.4 MB kept”).
  • linux-x64.tar.gz starts at 38 % on load. windows-x64.zip fails once at 62 %. checksums.txt starts done.
  • The accent is #c8f04a, paused #a9c447, error #ff6a4d.
  • Squares are 60px. Ring radius 17, stroke 2.5, circumference 106.8.

Implementation notes

Inverting label with one clip. Render the label twice. The dark copy lives inside the fill, and the fill is clipped from the right by the remaining percent. Set --p on the bar each frame.

.bar { position: relative; overflow: hidden; background: var(--surface-2); border-radius: 10px; }
.txt { position: absolute; inset: 0; display: flex; align-items: center; gap: 16px; padding: 0 20px; white-space: nowrap; }
.fill { position: absolute; inset: 0; background-color: var(--acid); color: var(--on-acid);
  clip-path: inset(0 calc(100% - var(--p, 0%)) 0 0);
  background-image: repeating-linear-gradient(-45deg, rgba(16,20,10,.09) 0 8px, transparent 8px 16px);
  background-size: 22.63px 22.63px; }
[data-state=running] .fill { animation: stripes 700ms linear infinite; }
@keyframes stripes { to { background-position: 22.63px 0; } }

22.63px is 16 × √2, which makes the −45° stripe tile with no visible join.

One loop, smoothed speed, single failure.

function tick(t) {
  const dt = last ? Math.min(.1, (t - last) / 1000) : 0; last = t;
  let any = false;
  for (const j of jobs) {
    if (j.state !== 'running') continue; any = true;
    j.v += (j.base * (.7 + Math.random() * .6) - j.v) * Math.min(1, dt * 3);   // ease toward a jittered target
    j.got = Math.min(j.total, j.got + j.v * dt);
    if (j.failAt && j.got >= j.failAt * j.total) { j.got = j.failAt * j.total; j.failAt = 0; j.set('error'); continue; }
    if (j.got >= j.total) { j.set('done'); continue; }
    j.paint();
  }
  raf = any ? requestAnimationFrame(tick) : (last = 0, 0);
}

Clamp dt to 100ms so a backgrounded tab doesn’t jump to done on return. In a real app, replace the simulator with fetch plus a ReadableStream reader for bytes, and with Range: bytes=N- for resume.

Ring. stroke-dasharray: 106.8; stroke-dashoffset: calc(106.8 * (1 - var(--f))), rotated −90° so it starts at 12 o’clock. Only rotate the ring SVG, not the centre icon. A broad .ring > svg selector turns the pause bars sideways.

Common mistakes:

  • Putting the overlay bar on top of the idle button without pointer-events: none. It silently eats the first click.
  • Animating width on the fill. Use clip-path or scaleX, and keep the label layers aligned.
  • Proportional digits for bytes and speed. The line shimmies every frame.
  • Restarting from zero on retry. Keep the bytes and say so (“8.6 / 18.4 MB kept”).
  • Announcing every percent to screen readers.
  • Leaving the stripe animating on a paused or failed bar. It tells the user something is moving when nothing is.

Details

Palette
Type
Epilogue · Fragment Mono
Motion
Subtle motion
Build
Multi-step
Designed bySusan AcharyaKathmandu

Studied

UseArticle

Let the visitor press the controls and watch the card change. Thick black edges and hard shadows keep it friendly.

All sources