组件
下载 Download
整合文件名识别、文件类型图标推导、下载进度跟踪与完成反馈于一体的卡片式下载组件。
基础用法
点击下载卡片启动下载流程,内置平滑的进度条与完成状态切换:
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"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 按钮的原生属性:
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| filename | string | — | 下载文件名称(例如 'report.pdf')。组件会自动根据扩展名匹配对应的 Lucide 文件类型图标。 |
| meta | string | — | 副标题元数据信息(例如 'ZIP · 48.2 MB')。idle 状态直接展示;其余状态会在其后拼接当前进度或结果(如 'ZIP · 48.2 MB · 已下载 64%')。未提供时仅显示状态文案。 |
| fileIcon | React.ReactNode | false | — | 自定义左侧文件图标;若传 false 则隐藏左侧图标区域。 |
| href | string | — | 文件直链地址。提供时点击卡片将自动触发原生浏览器文件保存。 |
| status | "idle" | "downloading" | "complete" | "error" | "idle" | 下载生命周期状态。idle 为就绪,downloading 为正在下载中,complete 为下载完成,error 为异常中断重试。 |
| progress | number | 0 | 当前下载百分比进度(0 ~ 100)。若 progress 为 0 且处于 downloading 状态,则呈现不确定无限往返流光动画。 |
| size | "default" | "compact" | "default" | 卡片的物理尺寸密度。default 适合独立卡片展示,compact 适合列表或附件栏排布。 |
| onDownload | () => void | Promise<void> | — | 用户触发下载时的异步回调函数。 |
| className | string | — | 应用于外层按钮卡片的额外 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时自动关闭过渡动效。