Design Lounge
Install

Nº 022 of 520

Ink-fill upload progress

A full-screen upload state where a 168px outlined wordmark fills with ink from the bottom via clip-path as progress advances, with a mono percentage, ETA, speed and per-file checks; click replays.

Preview

Open full size
1280 × 800 · 8.4 KB · 98 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 "Ink-fill upload progress" (piece ink-fill-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

191 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 blocking “uploading” screen of a media tool (“Anvil”). The product’s wordmark is set at 168px in Unbounded Black as a 2px outline; a second, solid copy of the same word sits exactly on top and is revealed from the bottom with clip-path: inset() as the upload progresses, so the letters appear to fill with ink. A faint 2px horizontal rule marks the ink level across the whole stage. Left: the three files with checkboxes that tick as each finishes. Right: a 56px tabular percentage with a seconds-remaining line. Bottom: a 3px progress hairline and the transfer speed. At 100 % the status dot turns green, a pill toast rises (“All files uploaded”) and the hairline turns green. Clicking anywhere (or pressing R) replays the 7.2s sequence. The mechanism worth copying is the two-layer wordmark with negative clip insets so the outline and fill never disagree.

Reference behaviour

  1. On load the upload starts at 0 %: hollow wordmark, no ink line visible above the baseline, percentage “0”, ETA “calculating”, speed ”— MB/s”, first file row marked live (dark checkbox border), status “Uploading” with a pulsing dot.
  2. Progress runs for 7,200ms along an ease-in-out curve with a slight wobble in the first half (so speed reads as variable), reaching 100 exactly at the end. Every frame: --p on <body> is set to the integer percentage; the percentage text, aria-valuenow, ETA (“N s remaining”), speed (total / duration × (0.7–1.3) MB/s) and the bottom hairline width update.
  3. The solid wordmark is clipped from the top by (100 − p) %, so the ink rises from the baseline; the ink-level rule follows at the same height with 25 % opacity.
  4. File rows complete at thresholds 57 % and 92 % (file 1 and 2); the last completes at 100 %. A completed row shows a green filled checkbox with a white check; the current row shows a dark outline.
  5. At 100 %: <body> gains done; the status dot turns green and stops pulsing; status reads “Complete”; ETA reads “done”; speed reads “230 MB sent”; hairline turns green; the ink-level rule fades; the toast slides up 12px and fades in over 600ms.
  6. Click anywhere, press R, or activate the visually hidden “Replay upload” button: everything resets and the sequence starts again from 0 (the ink drains instantly, then fills).
  7. Hover anywhere: the “Click anywhere to replay” hint brightens from --ink-3 to --ink.

Structure

1280 × 800
┌────────────────────────────────────────────────────────────────────────┐
│ ANVIL · UPLOADS                                        ● UPLOADING     │ top 28/40
│                                                                        │
│ ☑ site-footage-01.mov · 142 MB                                         │
│ ☐ site-footage-02.mov · 88 MB      ▄▄  ▄▄ ▄▄  ▄▄ ▄▄  ▄▄  ▄▄     51 %   │
│ ☐ captions-en.srt · 18 KB    ─────██──███──██──███──██───   4 S REM…   │ stage grid
│ 3 files · 230 MB · to eu-north-1  ████ ████ ████ ████ ████             │ 260 / 1fr / 180
│                                                                        │
│                                        (toast at bottom-centre)        │
│ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━───────────────────────────────────── │ 3px on 1px hairline
│ 36.9 MB/S                                     ↺ CLICK ANYWHERE TO REPLAY│ bottom 28/40
└────────────────────────────────────────────────────────────────────────┘
  • <body id="body">: grid-template-rows: auto 1fr auto, cursor: pointer, user-select: none.
  • .top: brand + section, .status (8px dot + #state).
  • <main class="stage" aria-label="Upload progress">: grid 260px 1fr 180px, 40px side padding, items centred vertically.
    • .meta#files: three .row[data-at] (checkbox <i> with an inline check SVG, filename <b>, size) and a summary line.
    • .word (aria-hidden): display: grid; .hollow and .fill spans both at grid-area: 1 / 1; .line absolutely positioned.
    • .pct[role="progressbar"]: <b id="num"> and <span id="eta">.
    • .toast (pill) and a visually hidden <button class="sr">Replay upload</button>.
  • .bar (1px hairline, ::after is the 3px progress).
  • .bottom: #speed and .replay hint with a 14px rotate-ccw icon.

Tokens

:root {
  /* colour — warm paper, navy ink, one green for completion */
  --paper: #f4efe6;
  --paper-2: #eae3d6;      /* reserved: hover surfaces */
  --ink: #101a2e;          /* wordmark, numerals, hairline fill */
  --ink-2: #4e5566;        /* labels */
  --ink-3: #8b8f99;        /* hint, unit sign, summary */
  --line: #d8d1c4;         /* hairline, empty checkbox */
  --done: #1f6f4a;         /* completed state */

  /* type */
  --display: "Unbounded", system-ui, sans-serif;   /* weight 900 only */
  --mono: "DM Mono", ui-monospace, monospace;
  --word-size: 168px; --fs-pct: 56px; --fs-unit: 22px; --fs-label: 12px; --fs-file: 12px;
  --word-tracking: -.04em; --word-stroke: 2px;

  /* layout */
  --gutter: 40px; --bar-pad: 28px 40px; --stage-cols: 260px 1fr 180px;
  --check: 14px; --dot: 8px; --hairline: 1px; --progress-h: 3px; --toast-bottom: 24px;
  --clip-bleed: -8px -4px -8px;   /* right / bottom / left negative insets on the fill clip */

  /* progress */
  --p: 0;                /* 0–100, integer, written by JS each frame */
  --upload-ms: 7200; --file-1-at: 57; --file-2-at: 92; --total-mb: 230;

  /* motion */
  --t-micro: 160ms; --t-fill: 120ms; --t-done: 600ms; --pulse: 1600ms;
  --ease: cubic-bezier(.2, .7, .2, 1);
  --ease-out: cubic-bezier(.16, 1, .3, 1);
}

Typography

RoleFamilySizeWeightLine-heightTrackingCase
WordmarkUnbounded168px900.95−0.04emUPPERCASE; hollow = transparent fill + 2px --ink stroke; fill = --ink + same stroke
PercentageDM Mono56px5001−0.04emtabular; ”%” via ::after at 22px in --ink-3
ETA / statusDM Mono12px4001.5+0.06emUPPERCASE --ink-2
Top / bottom barsDM Mono12px4001.5+0.06emUPPERCASE; brand 500 --ink
File rowsDM Mono12px4001.9+0.04emfilename 500 --ink, size --ink-2
Summary lineDM Mono12px4001.9+0.04em--ink-3
ToastDM Mono12px4001+0.06emUPPERCASE --paper on --ink

Motion

ElementTriggerPropertyFrom → ToDurationEasing
--p (JS)playcustom property0 → 100 along curve(t) (ease-in-out quad with a damped 6 % sine wobble)7200msrAF
.word .fill--p changeclip-path inset top100% → 0%120ms per steplinear
.word .line--p changetop100% → 0%, opacity .25 → 0 on done120mslinear
.bar::after--p changewidth0 → 100 %120mslinear
.status iwhile uploadingopacity1 → .3 → 11600ms--ease, infinite; removed on done
.row ithresholdbackground, bordertransparent/--line → --done/--done160mslinear
.toastdoneopacity, translateY0, 12px → 1, 0600ms--ease-out
.replay hintbody hovercolor--ink-3 → --ink160mslinear

Reduced motion: the status dot does not pulse; clip, hairline and toast transitions are 1ms (the fill still rises, stepwise with --p). The 7.2s progression itself is content, not decoration, and keeps running.

States

  • Uploading: dark pulsing dot, “Uploading”, ETA counting down, speed varying, hairline in --ink.
  • Row pending: 14px box with 1.5px --line border, hidden check. Row live: border --ink. Row done: --done fill and border, white check visible.
  • Done (body.done): green static dot, “Complete”, “done”, “230 MB sent”, green hairline, ink-level rule hidden, toast visible.
  • Hover (anywhere): hint text darkens; cursor is a pointer over the whole page.
  • Focus-visible (hidden replay button): appears at bottom-left as an ink pill with paper text.
  • No error state in this piece; add one by swapping --done for a red and the toast copy.

Accessibility

  • .pct is role="progressbar" with aria-valuemin/max/now updated every frame and aria-label="Upload progress".
  • The wordmark is aria-hidden (decorative duplicate of the brand); the percentage and file list carry the information.
  • The status text changes “Uploading” → “Complete”; wrap #state in aria-live="polite" in production so completion is announced (the demo leaves aria-live="off" on the body to avoid announcing every frame).
  • A visually hidden but focusable “Replay upload” button provides keyboard access to the click-anywhere behaviour; R also replays.
  • Contrast: --ink-2 on --paper 8.1:1; --ink-3 on --paper 3.9:1 (used only for the unit sign, hint and summary at 12px); --paper on --ink 15:1; --done fill with white check 5.3:1.
  • Text is not selectable (user-select: none) because the whole page is a click target; keep it that way only on this screen.

Responsive rules

  • 1100: as drawn; wordmark 168px, stage columns 260 / 1fr / 180.

  • 768–1100: wordmark 120px; stage columns 220 / 1fr / 140; percentage 40px (the 168px word needs ≈ 690px of free width, which 1024 does not have).
  • < 768: stage stacks (files, wordmark at 72px, percentage) with 20px gaps; percentage left-aligned; toast full-width pill above the hairline.

Acceptance checklist

  • Wordmark is two identical spans stacked with CSS grid (grid-area: 1/1), not absolute positioning; both have the same 2px text stroke.
  • The fill span uses clip-path: inset(calc((100 - var(--p)) * 1%) -8px -4px -8px) so the last glyph’s overhang is not clipped.
  • --p is an integer 0–100 set on <body> every animation frame; CSS derives the fill, the ink-level rule and the hairline from it alone.
  • The run lasts 7,200ms and ends at exactly 100 with the done class applied.
  • Percentage is 56px tabular mono with a 22px ”%” in --ink-3; ETA shows whole seconds remaining.
  • File rows tick at 57 %, 92 % and 100 % with a green filled checkbox and white check.
  • The toast fades and rises over 600ms with cubic-bezier(.16,1,.3,1) only after completion.
  • Clicking anywhere, pressing R, or activating the hidden Replay button restarts from 0 without stacking animation frames (cancelAnimationFrame first).
  • The status dot pulses (1.6s) only while uploading and never under reduced motion.
  • No setInterval; a single requestAnimationFrame loop drives the run.
  • Page fits 1280 × 800 without scrolling; the wordmark never overlaps the file list or percentage.

Implementation notes

Two-layer wordmark that cannot drift. Stack the layers with grid so they share one box, give both the same stroke so glyph rasterisation matches, and bleed the clip on three sides:

.word { display: grid; font: 900 var(--word-size)/.95 var(--display); letter-spacing: -.04em; white-space: nowrap; }
.word > span { grid-area: 1 / 1; }
.hollow { color: transparent; -webkit-text-stroke: 2px var(--ink); }
.fill   { color: var(--ink); -webkit-text-stroke: 2px var(--ink);
          clip-path: inset(calc((100 - var(--p)) * 1%) -8px -4px -8px);
          transition: clip-path 120ms linear; }

One rAF loop, one custom property. Everything visual reads --p; the JS only writes numbers and text:

const dur = 7200; let start = 0, raf = 0;
const curve = t => { const e = t < .5 ? 2*t*t : 1 - Math.pow(-2*t + 2, 2) / 2;
                     return Math.min(1, e * (1 - .06 * Math.sin(t * 9) * Math.max(0, 1 - t * 1.2))); };
function frame(now) {
  const t = Math.min(1, (now - start) / dur), p = Math.round(curve(t) * 100);
  body.style.setProperty('--p', p); num.textContent = p; pb.setAttribute('aria-valuenow', p);
  eta.textContent = t < 1 ? Math.max(0, Math.round((dur - (now - start)) / 1000)) + ' s remaining' : 'done';
  if (t < 1) raf = requestAnimationFrame(frame); else body.classList.add('done');
}
function play() { cancelAnimationFrame(raf); body.classList.remove('done'); start = performance.now(); raf = requestAnimationFrame(frame); }

Ink-level rule and hairline from the same number:

.word .line { position: absolute; left: -6%; right: -6%; height: 2px; background: var(--ink); opacity: .25;
              top: calc((100 - var(--p)) * 1%); transition: top 120ms linear; }
.bar::after { content: ""; position: absolute; left: 0; top: -1px; height: 3px; background: var(--ink);
              width: calc(var(--p) * 1%); transition: width 120ms linear; }

Common mistakes: clip-path: inset(... 0 0 0) clips the trailing glyph when letter-spacing is negative (bleed the insets); positioning the fill layer with position: absolute; inset: 0 (sub-pixel width differences show as a sliver at the last letter); animating height of a coloured overlay instead of clipping the text (the overlay covers counters and the ink level looks like a rectangle, not a fill); updating --p with a transition longer than the frame interval (the fill lags the number).

Details

Palette
Type
Unbounded · DM Mono
Motion
Rich motion
Build
One session
Designed bySusan AcharyaKathmandu

Studied

60fps

Show one small moment at a time, and let the tags do the sorting.

All sources