wui
组件

下载 Download

整合文件名识别、文件类型图标推导、下载进度跟踪与完成反馈于一体的卡片式下载组件。

第三方依赖 · lucide-react第三方依赖 · class-variance-authority第三方依赖 · motion

基础用法

点击下载卡片启动下载流程,内置平滑的进度条与完成状态切换:

Loading…

安装与引入

通过 CLI 自动添加组件,或手动复制源码至项目中:

pnpm dlx @wui-design/cli@latest add @wui/download
安装图标与动效库依赖
pnpm add motion lucide-react class-variance-authority clsx tailwind-merge
复制组件源码到 components/ui/download.tsx
components/ui/download.tsx
"use client"

import * as React from "react"
import {
  CheckIcon,
  DownloadIcon,
  FileArchiveIcon,
  FileAudioIcon,
  FileCode2Icon,
  FileIcon,
  FileImageIcon,
  FileSpreadsheetIcon,
  FileTextIcon,
  FileVideoIcon,
  LoaderCircleIcon,
  PresentationIcon,
  RotateCcwIcon,
  type LucideIcon,
} from "lucide-react"
import { AnimatePresence, motion, useReducedMotion, type HTMLMotionProps } from "motion/react"
import { cva } from "class-variance-authority"

import { cn } from "@/lib/utils"

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

const fileTypeIcons: Record<string, LucideIcon> = {
  "7z": FileArchiveIcon,
  aac: FileAudioIcon,
  ai: FileImageIcon,
  avi: FileVideoIcon,
  bmp: FileImageIcon,
  csv: FileSpreadsheetIcon,
  doc: FileTextIcon,
  docx: FileTextIcon,
  flac: FileAudioIcon,
  gif: FileImageIcon,
  gz: FileArchiveIcon,
  html: FileCode2Icon,
  jpeg: FileImageIcon,
  jpg: FileImageIcon,
  js: FileCode2Icon,
  json: FileCode2Icon,
  md: FileTextIcon,
  mov: FileVideoIcon,
  mp3: FileAudioIcon,
  mp4: FileVideoIcon,
  ods: FileSpreadsheetIcon,
  odp: PresentationIcon,
  odt: FileTextIcon,
  pdf: FileTextIcon,
  png: FileImageIcon,
  ppt: PresentationIcon,
  pptx: PresentationIcon,
  rar: FileArchiveIcon,
  svg: FileImageIcon,
  tar: FileArchiveIcon,
  ts: FileCode2Icon,
  tsx: FileCode2Icon,
  txt: FileTextIcon,
  wav: FileAudioIcon,
  webm: FileVideoIcon,
  webp: FileImageIcon,
  xls: FileSpreadsheetIcon,
  xlsx: FileSpreadsheetIcon,
  xml: FileCode2Icon,
  zip: FileArchiveIcon,
}

function getFileTypeIcon(filename: string) {
  const extension = filename.split(".").pop()?.toLowerCase()
  return extension ? fileTypeIcons[extension] ?? FileIcon : FileIcon
}

const downloadVariants = cva(
  "group relative flex w-full items-center gap-3 overflow-hidden rounded-lg border bg-card text-left shadow-xs outline-none transition-[border-color,background-color,box-shadow] duration-300 hover:border-ring/50 hover:bg-accent/35 focus-visible:ring-[3px] focus-visible:ring-ring/35 disabled:pointer-events-none disabled:opacity-50",
  {
    variants: {
      size: {
        default: "min-h-20 px-4 py-3.5",
        compact: "min-h-14 rounded-md px-3 py-2.5",
      },
    },
    defaultVariants: { size: "default" },
  }
)

export interface DownloadProps
  extends Omit<HTMLMotionProps<"button">, "children" | "onClick"> {
  /** Main file label shown in the control. */
  filename: string
  /** Secondary metadata such as file type and size. */
  meta?: string
  /** File type icon inferred from the filename. Pass a node to override it or false to hide it. */
  fileIcon?: React.ReactNode | false
  /** Direct URL downloaded when the control is activated. */
  href?: string
  /** Visual lifecycle state. @default "idle" */
  status?: DownloadStatus
  /** Determinate progress from 0 to 100. */
  progress?: number
  /** Height and spacing preset. @default "default" */
  size?: "default" | "compact"
  /** Called when the user requests the download. */
  onDownload?: () => void | Promise<void>
}

/** A download action that communicates file identity, progress, and completion. */
function Download({
  className,
  filename,
  meta,
  fileIcon,
  href,
  status,
  progress = 0,
  size = "default",
  onDownload,
  disabled,
  ...props
}: DownloadProps) {
  const reduceMotion = useReducedMotion()
  const [internalStatus, setInternalStatus] = React.useState<DownloadStatus>("idle")
  const currentStatus = status ?? internalStatus
  const normalizedProgress = Math.min(100, Math.max(0, progress))
  const FileTypeIcon = getFileTypeIcon(filename)

  async function handleDownload() {
    if (currentStatus === "downloading") return
    if (status === undefined) setInternalStatus("downloading")

    try {
      if (href) {
        const anchor = document.createElement("a")
        anchor.href = href
        anchor.download = filename
        anchor.click()
      }
      await onDownload?.()
      if (status === undefined) setInternalStatus("complete")
    } catch {
      if (status === undefined) setInternalStatus("error")
    }
  }

  const copy = {
    idle: { eyebrow: "点击下载", action: "下载" },
    downloading: {
      eyebrow:
        normalizedProgress > 0
          ? `已下载 ${Math.round(normalizedProgress)}%`
          : "正在准备…",
      action: "下载中",
    },
    complete: { eyebrow: "已保存到本地", action: "已完成" },
    error: { eyebrow: "下载中断", action: "重试" },
  }[currentStatus]

  const subtitle =
    currentStatus === "idle"
      ? (meta ?? copy.eyebrow)
      : [meta, copy.eyebrow].filter(Boolean).join(" · ")

  const ease = [0.22, 1, 0.36, 1] as const

  return (
    <motion.button
      type="button"
      data-slot="download"
      data-status={currentStatus}
      aria-busy={currentStatus === "downloading" || undefined}
      className={cn(downloadVariants({ size }), className)}
      whileHover={reduceMotion || disabled ? undefined : { y: -1 }}
      whileTap={reduceMotion || disabled ? undefined : { scale: 0.985 }}
      transition={{ type: "spring", stiffness: 430, damping: 30, mass: 0.7 }}
      disabled={disabled}
      onClick={handleDownload}
      {...props}
    >
      {fileIcon !== false ? (
        <motion.span
          data-slot="download-icon"
          className={cn(
            "relative flex shrink-0 items-center justify-center rounded-md bg-muted text-foreground",
            size === "compact" ? "size-9" : "size-12"
          )}
          animate={reduceMotion ? undefined : { scale: currentStatus === "complete" ? [1, 1.08, 1] : 1 }}
          transition={{ duration: 0.4, ease }}
        >
          {fileIcon ?? <FileTypeIcon className={size === "compact" ? "size-4" : "size-5"} />}
        </motion.span>
      ) : null}

      <span className="min-w-0 flex-1">
        <span className="block truncate text-sm font-medium">{filename}</span>
        <span
          aria-live="polite"
          className={cn(
            "mt-0.5 block truncate text-xs tabular-nums transition-colors",
            currentStatus === "error" ? "text-destructive" : "text-muted-foreground"
          )}
        >
          {subtitle}
        </span>
        <AnimatePresence initial={false}>
          {currentStatus === "downloading" ? (
            <motion.span
              key="progress"
              data-slot="download-progress"
              className="block overflow-hidden rounded-full bg-muted"
              initial={reduceMotion ? false : { height: 0, marginTop: 0, opacity: 0 }}
              animate={{ height: 4, marginTop: 8, opacity: 1 }}
              exit={reduceMotion ? undefined : { height: 0, marginTop: 0, opacity: 0 }}
              transition={{ duration: reduceMotion ? 0 : 0.24, ease }}
            >
              <motion.span
                className="block h-full origin-left rounded-full bg-primary"
                initial={false}
                animate={{ scaleX: normalizedProgress > 0 ? normalizedProgress / 100 : 0.28, x: normalizedProgress > 0 ? 0 : ["-120%", "350%"] }}
                transition={normalizedProgress > 0 || reduceMotion ? { duration: reduceMotion ? 0 : 0.35, ease } : { duration: 1.2, repeat: Infinity, ease: "easeInOut" }}
              />
            </motion.span>
          ) : null}
        </AnimatePresence>
      </span>

      <span className="flex shrink-0 items-center gap-2 text-xs font-medium text-muted-foreground">
        {size === "default" ? <span className="hidden sm:inline">{copy.action}</span> : null}
        <span
          data-slot="download-action"
          className={cn(
            "relative flex size-8 items-center justify-center rounded-full transition-colors duration-300",
            currentStatus === "idle" && "bg-primary text-primary-foreground",
            currentStatus === "downloading" && "bg-muted text-foreground",
            currentStatus === "complete" && "bg-success text-success-foreground",
            currentStatus === "error" && "bg-destructive text-destructive-foreground"
          )}
        >
          {currentStatus === "downloading" && normalizedProgress > 0 ? (
            <svg aria-hidden viewBox="0 0 32 32" className="absolute inset-0 size-8 -rotate-90">
              <motion.circle
                cx="16"
                cy="16"
                r="14.5"
                fill="none"
                strokeWidth="3"
                strokeLinecap="round"
                className="stroke-primary"
                initial={false}
                animate={{ pathLength: normalizedProgress / 100 }}
                transition={{ duration: reduceMotion ? 0 : 0.35, ease }}
              />
            </svg>
          ) : null}
          <AnimatePresence mode="popLayout" initial={false}>
            <motion.span
              key={currentStatus}
              className="flex items-center justify-center"
              initial={reduceMotion ? false : { opacity: 0, scale: 0.4, rotate: -45 }}
              animate={{ opacity: 1, scale: 1, rotate: 0 }}
              exit={reduceMotion ? undefined : { opacity: 0, scale: 0.4, rotate: 45 }}
              transition={reduceMotion ? { duration: 0 } : { type: "spring", stiffness: 520, damping: 32, mass: 0.7 }}
            >
              {currentStatus === "downloading" ? (
                normalizedProgress > 0 ? (
                  <DownloadIcon className="size-3.5" />
                ) : (
                  <LoaderCircleIcon className="size-4 animate-spin motion-reduce:animate-none" />
                )
              ) : null}
              {currentStatus === "complete" ? <CheckIcon className="size-4" /> : null}
              {currentStatus === "error" ? <RotateCcwIcon className="size-4" /> : null}
              {currentStatus === "idle" ? <DownloadIcon className="size-4" /> : null}
            </motion.span>
          </AnimatePresence>
        </span>
      </span>
    </motion.button>
  )
}

export { Download, downloadVariants }

属性 Props

Download 支持以下配置属性,并继承 Motion 按钮的原生属性:

属性类型默认值说明
filenamestring—下载文件名称(例如 'report.pdf')。组件会自动根据扩展名匹配对应的 Lucide 文件类型图标。
metastring—副标题元数据信息(例如 'ZIP · 48.2 MB')。idle 状态直接展示;其余状态会在其后拼接当前进度或结果(如 'ZIP · 48.2 MB · 已下载 64%')。未提供时仅显示状态文案。
fileIconReact.ReactNode | false—自定义左侧文件图标;若传 false 则隐藏左侧图标区域。
hrefstring—文件直链地址。提供时点击卡片将自动触发原生浏览器文件保存。
status"idle" | "downloading" | "complete" | "error""idle"下载生命周期状态。idle 为就绪,downloading 为正在下载中,complete 为下载完成,error 为异常中断重试。
progressnumber0当前下载百分比进度(0 ~ 100)。若 progress 为 0 且处于 downloading 状态,则呈现不确定无限往返流光动画。
size"default" | "compact""default"卡片的物理尺寸密度。default 适合独立卡片展示,compact 适合列表或附件栏排布。
onDownload() => void | Promise<void>—用户触发下载时的异步回调函数。
classNamestring—应用于外层按钮卡片的额外 CSS 类名。

事件 Events

属性类型默认值说明
onDownload() => void | Promise<void>—用户点击卡片触发下载时调用,可返回 Promise 自动等待异步任务结束。
onClick(event: React.MouseEvent<HTMLButtonElement>) => void—原生按钮点击事件。
onFocus(event: React.FocusEvent<HTMLButtonElement>) => void—卡片获得键盘焦点时触发。
onBlur(event: React.FocusEvent<HTMLButtonElement>) => void—卡片失去键盘焦点时触发。

使用场景与设计规范

Download 专为导出报表、资源包分发、多媒体附件与文档下载设计:

  • 自动推导 30+ 种文件格式图标:内置对 pdf, zip, mp4, xlsx, docx, ts, svg, key 等主流后缀的智能映射,开发者只需传入 filename="Design.zip" 即可自动渲染对应的压缩包图标。
  • 确切进度 vs 不确定进度:
    • 若能获取真实下载字节数,传入 progress={percent},展示平滑增长的进度条。
    • 若处于服务端打包或流式准备阶段无法预估大小,保持 progress={0},组件会自动展示流光往返呼吸态。
  • 微动效与反馈:进度条随下载开始平滑展开、结束后收起;右侧操作按钮在确切进度下绘制环形进度,并在“下载 → 进度 → 完成 / 重试”之间以旋转缩放交叉切换,底色同步过渡为成功或警示色;下载完成时文件图标轻微弹跳。

场景示例

各生命周期状态与自定义图标

对比 idle、downloading(确切进度)、complete、error 以及自定义文件图标:

Loading…

紧凑附件列表 (Compact List)

在消息气泡底部或邮件附件区域使用 size="compact" 密集排布多个文件:

Loading…

无障碍与交互 Accessibility

  • 语义按钮结构:外层基于 <button type="button"> 构建,原生支持键盘 Enter / Space 触发。
  • 动态状态播报:副标题为 aria-live="polite" 区域,根据 status 自动播报“正在准备…”、“已下载 64%”、“已保存到本地”等文案;下载中按钮声明 aria-busy。
  • 动效降级:进度条填充与图标缩放弹簧在检测到 prefers-reduced-motion 时自动关闭过渡动效。