Download Row
A per-transfer mobile row whose rate and ETA disappear the moment bytes stop arriving, with pause, resume, retry and a press-and-hold cancel that answers to the keyboard.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/download-row.jsonPrompt
Build a React + TypeScript + Tailwind "DownloadRow" component (React + lucide-react
only — no state machine library, no animation library; every moving pixel is a CSS
transition or one keyframe pair shipped inside the file).
It is a mobile component on purpose: the controls are 44px targets on the trailing
edge where a thumb lands, the last row of a docked stack pads itself with
env(safe-area-inset-bottom), cancelling is a press-and-hold because a phone has no
room for a confirm dialog next to the button, and the whole premise — a transfer
that stops without telling anyone — is a cellular problem, not a desktop one.
Contract
- forwardRef<HTMLDivElement>, extending Omit<React.HTMLAttributes<HTMLDivElement>,
"title"> (the row renders its own title, so the native attribute is dropped rather
than fought over). Rest props spread on the root; className merges through cn().
- Data: title: string, received = 0, total?: number, rate?: number | null (bytes per
second, straight from the transfer layer), reason?: string ("Waiting for Wi-Fi",
"Not enough space — free up 1.2 GB").
- Time: now?: number and lastByteAt?: number, both ms since epoch. NOTHING in the
component reads a clock — not at render, not in an effect — so the server and the
first client frame print the same string and a test can drive it frame by frame.
stallAfterMs = 6000 (clamped 500..600000) is how long a "downloading" row may stay
quiet before it is *presented* as stalled.
- State: status?: DownloadStatus (controlled) | defaultStatus = "queued"
(uncontrolled) + onStatusChange?. DownloadStatus = "queued" | "downloading" |
"stalled" | "paused" | "error" | "done" | "canceled". Actions:
onPause / onResume / onRetry / onCancel / onOpen, all optional.
- Presentation: variant: "bar" (default) | "ring" | "rail", icon?: ReactNode,
safeAreaBottom = false, holdToCancel = true, cancelHoldMs = 550 (clamped
200..4000), labels?: Partial<DownloadRowLabels> for the state words and control
names.
- Availability rule, stated once: a control is live when the matching callback is
given, OR the row is uncontrolled, OR onStatusChange is given. Otherwise it renders
aria-disabled. A controlled row wired to nothing must announce that it cannot act
instead of looking pressable — no dead buttons.
Behavior
- The honest rate is the product. Throughput and ETA are drawn only while the row is
genuinely moving: status is "downloading" AND rate is a finite number above zero.
The component never derives a rate from byte deltas and never keeps the last value
on screen. Independently, if now - lastByteAt >= stallAfterMs the row *presents* as
stalled — it prints "Stalled, no data for 14s", drops the rate and the ETA, and
greys the fill — without rewriting the status prop, so a naive transfer layer that
keeps reporting 2.4 MB/s into a dead socket still cannot lie through this row.
Callers must re-baseline lastByteAt when they resume, or the row is stalled the
instant it comes back.
- ETA = remaining / rate, printed coarsely (12s / 4m / 2h — "4m 12s" pretends the
estimate is that good) and suppressed entirely past ~100h. The ETA slot keeps its
line height when it empties: a transfer going quiet must not resize the card under
a thumb that is reaching for the button.
- Unknown length. total omitted, 0 or non-finite means no Content-Length: no
percentage anywhere, an indeterminate sliver sweeping the track (or a quarter arc
spinning in the ring), aria-busy, and the meta line reading "7.3 MB · size unknown".
received above total is clamped; NaN and Infinity are treated as absent, so a lying
server degrades to indeterminate instead of painting a 4000% bar.
- The primary control is derived, never configured: queued / downloading / stalled →
Pause; paused → Resume; error / canceled → Retry; done → Open when onOpen is given,
otherwise no primary at all. Cancel is shown for everything except the two terminal
states — a cancelled transfer has nothing left to stop.
- Cancel is a hold. Pointer Events only (never parallel mouse and touch handlers):
pointerdown ignores non-primary mouse buttons, calls setPointerCapture on the button
that started it, and starts a cancelHoldMs timer. Drifting more than 24px abandons
the charge, and so does pointercancel — the browser taking the pointer for a scroll
is not an intent to destroy anything. The button's touch-action is "manipulation",
NOT "none": the row must never fight the list it lives in, and letting the browser
win a scroll is exactly what makes an accidental hold impossible. contextmenu is
prevented while holding so the system callout does not open mid-charge; that is a
real listener, so preventDefault is legal there. The timer is the truth: on fire it
re-checks that cancelling still means anything (a transfer can finish under the
thumb) and then runs onCancel + status "canceled" behind a one-shot ref read AND
written inside the handler. Releasing early announces "Hold to cancel" instead of
doing nothing at all. holdToCancel={false} switches to a single press for apps that
raise their own confirmation dialog.
- Keyboard is the same mechanic, not a lesser path. On the cancel button, Enter or
Space charges on keydown (preventDefault: Space would scroll and Enter would
synthesise a click) and settles on keyup; event.repeat is ignored so auto-repeat
cannot restart the charge; Escape and blur abandon it. In hold mode the click
handler is a no-op, so nothing can fire the action twice.
- Focus never falls to <body>. The cancel button leaves on a terminal state, and the
primary leaves when a finished row has no Open; a layout effect keyed on the control
set hands focus to the primary button, or to the root (tabIndex={-1}) when even that
is gone. It fires only when this row was the one holding focus — a focusin/focusout
pair on the root tracks that, treating a focusout with a null relatedTarget as
"the control was removed" and one pointing at an element outside the row as "the
user left on purpose". Without that guard, nineteen rows finishing in the background
would each yank the caret off the twentieth. Nothing is ever natively disabled —
aria-disabled plus a guard in the handler, because the browser blurs a control the
instant it becomes disabled.
- Announcements are status and reason only, never digits: a row fed a live clock would
otherwise re-announce itself every second. One polite sr-only role="status" carries
"<title>: Stalled. Waiting for Wi-Fi", and the hint replaces it for ~3.2s before
clearing (clearing is what lets an identical next hint be spoken again).
- Cleanup: hold timer and hint timer are cleared on unmount; the focusin/focusout pair
is removed with them; pointer capture is released on up, on the 24px abandon and by
the browser on cancel. No listener, observer or rAF outlives the row.
Rendering & styling
- Semantic tokens only, monochrome first: bg-card / border / rounded-2xl card,
bg-muted leading tile and progress track, text-foreground title, text-muted-
foreground meta. The determinate fill is bg-foreground while it moves and
bg-muted-foreground/50 while it does not — "not moving" is a colour-free statement.
destructive is spent only where it is earned: the failed row's meta line, the alert
glyph, the cancel charge (bg-destructive/25) and its ring. The primary control
INVERTS (bg-foreground text-background) rather than taking a hue.
- Three variants, one contract: "bar" draws a 4px track under the text with a
percent / ETA foot line; "ring" turns the leading tile into a 40px SVG ring with the
percentage inside and drops the bar; "rail" is two lines with a hairline on the
card's own bottom edge, clipped by a rounded-[inherit] wrapper so it follows the
radius. Whichever node draws progress carries role="progressbar",
aria-labelledby={titleId}, aria-valuemin/max 0..100, aria-valuenow (absent when
indeterminate) and an aria-valuetext like "42%, 31.4 MB of 78 MB, Downloading".
- Touch: every control is size-11 (44px), rounded-full, focus-visible:outline-2 with
a negative-free offset, and nothing depends on hover. The root is touch-pan-y.
- Motion: the fill transitions width/stroke-dashoffset over 500ms and the indeterminate
sweep is a shipped keyframe pair (hoisted <style href=… precedence>, so twenty rows
emit one). Under prefers-reduced-motion the sweep rests at the start, the press
scale is dropped, and the cancel charge is display:none rather than a transition
jumping to full — the destructive ring and tint still say the press was taken, and
the timer behind it is unchanged.
- Title truncates as a flex item (min-w-0 + truncate) so a 60-character file name ends
in an ellipsis instead of pushing the controls off a 390px screen. Numbers are
tabular-nums so a changing percentage does not jitter.
- safeAreaBottom adds pb-[calc(env(safe-area-inset-bottom)+0.75rem)] and lifts the
rail hairline to bottom-[env(safe-area-inset-bottom)]: docked at the bottom of a
sheet, the controls clear the home indicator.
Customization levers
- Feel: cancelHoldMs (550) is how deliberate a cancel must be — raise it for a
transfer that cost the user an hour, or pass holdToCancel={false} and confirm your
own way. The 24px drift and the 3.2s hint window are the other two constants.
- Honesty: stallAfterMs (6000) is the only judgement call in the file. Lower it on a
metered connection where a two-second gap already matters; raise it for a
chunk-at-a-time API that legitimately goes quiet between chunks.
- Density: swap "bar" → "rail" for a docked stack, "ring" for a long sheet list; drop
the leading tile by passing your own icon, or move the meta line to two lines by
splitting the joined parts. p-4 / gap-2 is the comfortable phone density; px-4 py-3
is the dense one.
- Vocabulary: labels replaces every state word and control name; the numeric phrases
("3.1 MB of 7.4 MB", "8s left") are composed in English and are the one place a full
i18n has to reach into the file.
- Composition: the row renders a plain div — pass role="listitem" inside a role="list",
wrap it in your own sheet, or hang swipe actions off it. onOpen, onRetry and friends
are where the consumer talks to its transfer layer; the row owns none of that.Concepts
- Presented state vs reported state — the consumer owns
status; the row owns what the user is told. A transfer that has heard nothing for longer thanstallAfterMsis drawn and announced as stalled while the prop still saysdownloading, so the truth about the network never has to round-trip through somebody else's state machine. - Honest throughput — rate and ETA exist only while bytes are landing. They are removed rather than frozen, because a rate left on screen after the radio went to sleep is the single most common lie a transfer UI tells.
- Injected instant —
nowandlastByteAtcome in as props; nothing calls a clock during render or in an effect. That is what makes the row deterministic under SSR, replayable in a test, and free of a timer per row in a list of twenty. - Hold to cancel — destroying a half-finished transfer needs deliberation, and a phone has no hover and no room for a dialog. The 550ms charge runs identically under a thumb and under a held Enter or Space; a short press explains itself; a scroll that steals the pointer abandons it without a word.
- Thumb-reach controls, safe-area edge — the controls are 44px circles on the trailing edge, the primary inverted rather than coloured, and the last row of a docked stack pads itself with
env(safe-area-inset-bottom)so nothing sits under the home indicator. - Determinate, indeterminate, and neither — three genuinely different renderings out of one contract: a percentage when the length is known, a sweep with
aria-busywhen it is not, and a grey resting fill when nothing is moving at all.
Mini Player
A now-playing bar docked above the tab bar that a swipe up, a tap or a key morphs into a full-screen player, with safe-area padding and a read-only timeline when no seek handler is wired.
Sticky Section List
A grouped mobile list whose section headers pin to the safe-area line and push each other out, with a thumb-arc pager that walks the sections and reports the one being read.