Buttons
Download Button
A download CTA that fills like a liquid gauge as the transfer advances, then settles into a checked or retryable state.
Preview in your theme
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/download-button.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "DownloadButton" component using
lucide-react icons.
Contract
- Export a forwardRef <button> extending React.ButtonHTMLAttributes plus:
status?: "idle" | "downloading" | "success" | "error" (default "idle")
progress?: number 0..100, clamped and rounded internally
onDownload?: () => void fired on activation from idle / success / error
label / downloadingLabel / successLabel / errorLabel?: string
- Status is controlled: the component never advances itself. The consumer owns
the transfer (fetch + ReadableStream, XHR onprogress, a job poll) and feeds
status + progress back in. Consumer onClick still fires first.
- Re-entry while downloading is blocked by an early return in the click handler,
NOT by the native `disabled` attribute: disabling a button the instant Enter
activates it makes the browser blur it and drops the keyboard on document.body
for the rest of the transfer. `disabled` stays reserved for the consumer's own
prop. onDownload therefore only re-fires from a settled state, which is what
makes the error state a retry affordance.
Behavior
- The fill layer is an absolutely positioned span, inset-y-0 left-0, whose width
is the clamped percentage; the button is overflow-hidden and rounded so the
liquid is clipped to the shape. A 2px bright strip pinned to the fill's right
edge reads as the waterline.
- Success pins the fill at 100 regardless of the incoming progress, so a
transfer that reports 97 never settles half-full.
- Width stability: all four labels render into the same CSS grid cell; the three
inactive ones stay `invisible` but keep their natural width, so the button is
as wide as its widest state and cannot jump when the text changes. Inside the
downloading label the number sits in an inline-block with min-w-[4ch] and
tabular-nums, so 7% → 42% → 100% does not shift anything either.
- The active label layer plays a 200ms fade-up keyframe (React 19 hoisted
<style href precedence>), which retriggers naturally because the animation
property is only applied to the current layer.
- prefers-reduced-motion: motion-reduce:transition-none on the fill and
motion-reduce:[animation:none] on the label layer — the width still tracks the
percentage exactly, it just snaps instead of easing.
Rendering & styling
- Semantic tokens only. idle: bg-primary + text-primary-foreground.
downloading / success: bg-primary/15 + text-primary with a bg-primary/35 fill
and a bg-primary/70 waterline. error: bg-destructive/15 + text-destructive
with a hover step. No hardcoded colours; the tinted track keeps the label
readable over both the filled and unfilled halves in light and dark.
- Accessibility: a <button>'s own subtree is presentational, so anything nested
inside it never reaches the accessibility tree. Wrap the whole thing in a
`<span className="contents">` (no box, so the caller's layout is untouched) and
make the two assistive-tech nodes *siblings* of the button: an sr-only
role="progressbar" (aria-valuemin/max/now + aria-label) rendered only while
downloading, and an aria-live="polite" sr-only span. The fill layer stays inside
the button as aria-hidden decoration. The button keeps aria-busy plus an
aria-label restating status and percentage; the live region announces the coarse
status word only — never the per-tick percentage — so screen readers are not
spammed 14 times a second. `disabled` still lands on the button, and the
downloading state shows a cursor-progress instead of pretending to be clickable.
- Icons: Download when idle, none while downloading, Check on success,
RotateCcw on error (the glyph itself signals "click me again").
Customization levers
- Fill opacities: bg-primary/35 for the liquid and /70 for the waterline are the
contrast budget — raise them together, and re-check the label against the
filled half in dark mode.
- Shape: swap rounded-lg for rounded-full to read as a pill gauge; overflow
hidden already clips the fill to whatever radius you pick.
- Direction: flip the fill to `inset-x-0 bottom-0` with a height percentage for
a vertical, tank-style fill.
- Copy: the four label props are the whole i18n surface; keep them short enough
that the widest one still fits the min-w-40 floor.
- Error tone: swap the destructive tokens for muted if a failed export should
read as neutral rather than alarming.
- Idle emphasis: drop the solid bg-primary for the same tinted track used while
downloading if the button must sit quietly in a toolbar.Concepts
- Progress as surface — the button is its own gauge: the filled fraction is the background, so a row of file actions does not need a second progress element competing for space.
- Determinate vs indeterminate — this component only makes sense when the percentage is real; when it is not, a spinner-style status machine is the honest choice and a fake-moving bar is not.
- Width-stable label stack — every label variant occupies the same grid cell and the inactive ones stay invisible but measured, which is what stops the button from resizing as the copy changes mid-transfer.
- Controlled status — the component never advances itself, so the same markup serves an optimistic local simulation, a streamed fetch and a polled server job without any internal timers to keep in sync.
- Retry as a state, not a second button — the error state reuses the same control with destructive tokens and a rotate glyph, so recovery costs the layout nothing and keeps the action in one place.
- Announce coarsely, expose precisely — the live region speaks the status word only while the exact percentage lives on the progressbar node, which keeps assistive tech informed without narrating every tick.
- Semantics live outside the button — a button's subtree is presentational, so the progressbar and the live region sit next to the button (inside a
display: contentswrapper that adds no box) rather than inside it; nested there they would render, look right, and reach no screen reader at all. - Busy is not disabled — the transfer is guarded in the click handler and advertised with
aria-busy, because flipping the nativedisabledattribute mid-activation blurs the button and strands keyboard focus ondocument.bodyuntil the download ends.