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…

"use client"

import * as React from "react"
import { Check, Download, RotateCcw } from "lucide-react"
import { cn } from "@/lib/utils"

/** Label layers fade up as the status changes. React 19 hoisted style, dedupes by href. */
const KEYFRAMES = `@keyframes db-state-in{from{opacity:0;transform:translateY(3px)}to{opacity:1;transform:none}}`

export type DownloadStatus = "idle" | "downloading" | "success" | "error"

const STATUSES: DownloadStatus[] = ["idle", "downloading", "success", "error"]

export interface DownloadButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/download-button.json

Prompt

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: contents wrapper 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 native disabled attribute mid-activation blurs the button and strands keyboard focus on document.body until the download ends.

On This Page