组件
上传 Upload
支持点击选取、文件拖拽放置、上传进度百分比展示与状态生命周期反馈的文件上传控件。
基础用法
最简单的文件上传用法。点击区域选择文件,或直接将文件从桌面拖拽到虚线框内:
Loading…
安装与引入
通过 CLI 自动添加组件,或手动复制源码至项目中:
pnpm dlx @wui-design/cli@latest add @wui/upload安装基础依赖与动效库
pnpm add motion lucide-react class-variance-authority clsx tailwind-merge复制组件源码到
components/ui/upload.tsx"use client"
import * as React from "react"
import {
CheckIcon,
CircleAlertIcon,
CloudUploadIcon,
FileIcon,
LoaderCircleIcon,
RotateCcwIcon,
XIcon,
} 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 UploadStatus = "idle" | "uploading" | "complete" | "error"
const uploadVariants = cva(
"group relative flex w-full cursor-pointer flex-col items-center justify-center border border-dashed bg-background text-center outline-none transition-[border-color,background-color,box-shadow] hover:border-ring/60 hover:bg-accent/35 focus-within:border-ring focus-within:ring-[3px] focus-within:ring-ring/25 has-[:disabled]:pointer-events-none has-[:disabled]:opacity-50",
{
variants: {
size: {
default: "min-h-52 gap-4 px-6 py-8",
compact: "min-h-32 gap-3 px-4 py-5",
},
},
defaultVariants: { size: "default" },
}
)
export interface UploadProps
extends Omit<
React.ComponentProps<"input">,
"children" | "onChange" | "size" | "type" | "value"
> {
/** Main instruction displayed inside the drop zone. @default "拖拽文件到此处,或点击选择" */
label?: string
/** Supporting copy displayed under the main instruction. */
description?: string
/** Visual lifecycle state. @default "idle" */
status?: UploadStatus
/** Determinate upload progress from 0 to 100. */
progress?: number
/** Height and spacing preset. @default "default" */
size?: "default" | "compact"
/** Allows more than one file to be selected. @default false */
multiple?: boolean
/** Comma-separated list of accepted MIME types or file extensions. */
accept?: string
/** Called whenever files are selected or dropped. */
onFilesChange?: (files: File[]) => void
/** Called after file selection and reflected in the internal lifecycle state. */
onUpload?: (files: File[]) => void | Promise<void>
}
/** A click and drag-and-drop file picker with upload lifecycle feedback. */
function Upload({
className,
label = "拖拽文件到此处,或点击选择",
description,
status,
progress = 0,
size = "default",
multiple = false,
accept,
disabled,
onFilesChange,
onUpload,
...props
}: UploadProps) {
const reduceMotion = useReducedMotion()
const [internalStatus, setInternalStatus] = React.useState<UploadStatus>("idle")
const [dragging, setDragging] = React.useState(false)
const currentStatus = status ?? internalStatus
const normalizedProgress = Math.min(100, Math.max(0, progress))
async function handleFiles(fileList: FileList | null) {
if (!fileList?.length || disabled || currentStatus === "uploading") return
const files = Array.from(fileList)
const selectedFiles = multiple ? files : files.slice(0, 1)
onFilesChange?.(selectedFiles)
if (!onUpload) return
if (status === undefined) setInternalStatus("uploading")
try {
await onUpload(selectedFiles)
if (status === undefined) setInternalStatus("complete")
} catch {
if (status === undefined) setInternalStatus("error")
}
}
const copy = {
idle: { detail: description ?? "从设备中选择文件", icon: CloudUploadIcon },
uploading: {
detail: normalizedProgress > 0 ? `已上传 ${Math.round(normalizedProgress)}%` : "正在上传…",
icon: LoaderCircleIcon,
},
complete: { detail: "上传完成", icon: CheckIcon },
error: { detail: "上传失败,请重新选择文件", icon: RotateCcwIcon },
}[currentStatus]
const StatusIcon = copy.icon
const springTransition = reduceMotion
? { duration: 0 }
: { type: "spring" as const, stiffness: 420, damping: 32, mass: 0.72 }
return (
<motion.label
layout
data-slot="upload"
data-status={currentStatus}
data-dragging={dragging || undefined}
className={cn(
uploadVariants({ size }),
dragging && "border-ring bg-accent/55 ring-[3px] ring-ring/20",
className
)}
animate={reduceMotion ? undefined : { scale: dragging ? 1.01 : 1 }}
whileTap={reduceMotion || disabled ? undefined : { scale: 0.995 }}
transition={springTransition}
onDragEnter={(event) => {
event.preventDefault()
if (!disabled) setDragging(true)
}}
onDragOver={(event) => event.preventDefault()}
onDragLeave={(event) => {
event.preventDefault()
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setDragging(false)
}}
onDrop={(event) => {
event.preventDefault()
setDragging(false)
void handleFiles(event.dataTransfer.files)
}}
>
<input
data-slot="upload-input"
className="sr-only"
type="file"
accept={accept}
multiple={multiple}
disabled={disabled || currentStatus === "uploading"}
onChange={(event) => {
void handleFiles(event.currentTarget.files)
event.currentTarget.value = ""
}}
{...props}
/>
<motion.span
layout="position"
data-slot="upload-icon"
className={cn(
"relative flex items-center justify-center overflow-hidden rounded-full border bg-card text-foreground transition-colors duration-300",
currentStatus === "complete" && "border-primary bg-primary text-primary-foreground",
currentStatus === "error" && "border-destructive/30 text-destructive",
size === "compact" ? "size-10" : "size-12"
)}
animate={
reduceMotion
? undefined
: {
scale: currentStatus === "complete" ? [1, 1.08, 1] : dragging ? 1.06 : 1,
y: dragging ? -2 : 0,
}
}
transition={
currentStatus === "complete" && !reduceMotion
? { duration: 0.52, ease: [0.22, 1, 0.36, 1] }
: springTransition
}
>
<AnimatePresence initial={false} mode="wait">
<motion.span
key={currentStatus}
className="absolute flex items-center justify-center"
initial={
reduceMotion
? false
: { opacity: 0, scale: 0.62, y: 7, filter: "blur(4px)", rotate: 0 }
}
animate={{
opacity: 1,
scale: currentStatus === "complete" && !reduceMotion ? [0.8, 1.16, 1] : 1,
y: 0,
filter: "blur(0px)",
rotate: currentStatus === "uploading" && !reduceMotion ? 360 : 0,
}}
exit={
reduceMotion
? { opacity: 0 }
: { opacity: 0, scale: 0.68, y: -7, filter: "blur(4px)" }
}
transition={
currentStatus === "uploading" && !reduceMotion
? {
opacity: { duration: 0.2 },
scale: springTransition,
y: springTransition,
filter: { duration: 0.2 },
rotate: { duration: 1.05, ease: "linear", repeat: Infinity },
}
: currentStatus === "complete" && !reduceMotion
? { duration: 0.5, ease: [0.22, 1, 0.36, 1] }
: springTransition
}
>
<StatusIcon className={size === "compact" ? "size-4" : "size-5"} />
</motion.span>
</AnimatePresence>
</motion.span>
<motion.span layout="position" className="space-y-1" transition={springTransition}>
<span className="block text-sm font-semibold tracking-[-0.01em]">{label}</span>
<span className="relative block min-h-4 overflow-hidden text-xs text-muted-foreground" aria-live="polite">
<AnimatePresence initial={false} mode="wait">
<motion.span
key={currentStatus}
className="block"
initial={reduceMotion ? false : { opacity: 0, y: 6, filter: "blur(3px)" }}
animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
exit={reduceMotion ? { opacity: 0 } : { opacity: 0, y: -6, filter: "blur(3px)" }}
transition={reduceMotion ? { duration: 0 } : { duration: 0.28, ease: [0.22, 1, 0.36, 1] }}
>
{copy.detail}
</motion.span>
</AnimatePresence>
</span>
</motion.span>
<AnimatePresence initial={false}>
{currentStatus === "uploading" ? (
<motion.span
layout="position"
data-slot="upload-progress"
className="block h-1 w-full max-w-48 overflow-hidden rounded-full bg-muted"
initial={reduceMotion ? false : { opacity: 0, scaleX: 0.72, y: 5 }}
animate={{ opacity: 1, scaleX: 1, y: 0 }}
exit={reduceMotion ? { opacity: 0 } : { opacity: 0, scaleX: 0.78, y: -4 }}
transition={springTransition}
>
{normalizedProgress > 0 ? (
<motion.span
className="block h-full w-full origin-left rounded-full bg-primary"
initial={false}
animate={{ scaleX: normalizedProgress / 100 }}
transition={springTransition}
/>
) : (
<motion.span
className="block h-full w-[28%] rounded-full bg-primary"
animate={reduceMotion ? { x: 0 } : { x: ["-130%", "460%"] }}
transition={
reduceMotion
? { duration: 0 }
: { duration: 1.2, ease: [0.45, 0, 0.55, 1], repeat: Infinity }
}
/>
)}
</motion.span>
) : null}
</AnimatePresence>
</motion.label>
)
}
function formatFileSize(bytes: number) {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
}
/** Animated container for UploadFileItem rows: rows slide in, fade out and the rest reflow smoothly. */
function UploadFileList({ className, children, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="upload-file-list"
className={cn("relative grid gap-2", className)}
{...props}
>
<AnimatePresence initial={false} mode="popLayout">
{children}
</AnimatePresence>
</ul>
)
}
export interface UploadFileItemProps extends Omit<HTMLMotionProps<"li">, "children"> {
/** File name shown as the primary text. */
name: string
/** File size in bytes, or a preformatted label. */
size?: number | string
/** Upload lifecycle of this file. @default "complete" */
status?: UploadStatus
/** Determinate progress from 0 to 100 while uploading. */
progress?: number
/** Helper or error text replacing the size line. */
description?: React.ReactNode
/** Leading visual such as a thumbnail; defaults to a file glyph. */
icon?: React.ReactNode
/** Shows a remove button and is called when it is pressed. */
onRemove?: () => void
}
/** One file row with a lifecycle icon, a smooth progress bar and an optional remove action. */
function UploadFileItem({
className,
name,
size,
status = "complete",
progress = 0,
description,
icon,
onRemove,
...props
}: UploadFileItemProps) {
const reduceMotion = useReducedMotion()
const normalizedProgress = Math.min(100, Math.max(0, progress))
const sizeLabel = typeof size === "number" ? formatFileSize(size) : size
const meta =
description ??
(status === "uploading"
? `${sizeLabel ? `${sizeLabel} · ` : ""}${Math.round(normalizedProgress)}%`
: status === "error"
? "上传失败"
: sizeLabel)
const spring = reduceMotion
? { duration: 0 }
: { type: "spring" as const, stiffness: 520, damping: 38, mass: 0.7 }
return (
<motion.li
layout={!reduceMotion}
data-slot="upload-file-item"
data-status={status}
className={cn(
"relative flex items-center gap-3 overflow-hidden rounded-md border bg-background px-3 py-2.5 text-sm transition-colors data-[status=error]:border-destructive/40",
className
)}
initial={reduceMotion ? false : { opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={
reduceMotion
? { opacity: 0 }
: { opacity: 0, scale: 0.97, transition: { duration: 0.16 } }
}
transition={spring}
{...props}
>
<span
className={cn(
"flex size-8 shrink-0 items-center justify-center overflow-hidden rounded-md bg-muted text-muted-foreground [&_svg]:size-4",
status === "error" && "bg-destructive/10 text-destructive"
)}
>
{icon ?? <FileIcon />}
</span>
<span className="grid min-w-0 flex-1 gap-0.5">
<span className="truncate font-medium leading-5">{name}</span>
{meta ? (
<span
className={cn(
"truncate text-xs tabular-nums text-muted-foreground",
status === "error" && "text-destructive"
)}
>
{meta}
</span>
) : null}
</span>
{status !== "idle" ? (
<span className="relative flex size-6 shrink-0 items-center justify-center [&_svg]:size-4">
<AnimatePresence initial={false} mode="popLayout">
<motion.span
key={status}
className={cn(
"flex items-center justify-center",
status === "complete" && "text-success",
status === "error" && "text-destructive",
status === "uploading" && "text-muted-foreground"
)}
initial={reduceMotion ? false : { opacity: 0, scale: 0.5 }}
animate={{ opacity: 1, scale: 1 }}
exit={reduceMotion ? { opacity: 0 } : { opacity: 0, scale: 0.5 }}
transition={spring}
>
{status === "uploading" ? (
<LoaderCircleIcon className="animate-spin motion-reduce:animate-none" />
) : status === "complete" ? (
<CheckIcon />
) : (
<CircleAlertIcon />
)}
</motion.span>
</AnimatePresence>
</span>
) : null}
{onRemove ? (
<button
type="button"
data-slot="upload-file-remove"
aria-label={`移除 ${name}`}
className="-mr-1 flex size-7 shrink-0 items-center justify-center rounded-md text-muted-foreground outline-none transition-colors hover:bg-accent hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/30 [&_svg]:size-3.5"
onClick={onRemove}
>
<XIcon />
</button>
) : null}
<AnimatePresence initial={false}>
{status === "uploading" ? (
<motion.span
key="progress"
aria-hidden="true"
data-slot="upload-file-progress"
className="absolute inset-x-0 bottom-0 h-0.5 bg-muted"
initial={reduceMotion ? false : { opacity: 0 }}
animate={{ opacity: 1 }}
exit={{
opacity: 0,
transition: { duration: reduceMotion ? 0 : 0.3, delay: reduceMotion ? 0 : 0.2 },
}}
>
<motion.span
className="block h-full origin-left bg-primary"
initial={false}
animate={{ scaleX: normalizedProgress / 100 }}
transition={spring}
/>
</motion.span>
) : null}
</AnimatePresence>
</motion.li>
)
}
export { Upload, UploadFileItem, UploadFileList, uploadVariants }
属性 Props
Upload 支持以下配置属性,并继承底层隐藏 <input type="file"> 的 HTML 属性:
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| label | string | "拖拽文件到此处,或点击选择" | 拖拽放置区域内的主标题文案引导。 |
| description | string | — | 主标题下方的副标题或文件格式、大小说明文案。 |
| status | "idle" | "uploading" | "complete" | "error" | "idle" | 受控模式下的上传生命周期状态。 |
| progress | number | 0 | 当前上传进度的百分比数值(0 ~ 100)。大于 0 时渲染确定进度条,为 0 时呈现不确定流动波纹动效。 |
| size | "default" | "compact" | "default" | 上传拖拽区域的物理尺寸密度。 |
| multiple | boolean | false | 是否允许一次性选择或拖放多个文件。 |
| accept | string | — | 文件选择器过滤的文件类型(如 `'image/*'`、`'.pdf,.docx'`、`'application/zip'`)。 |
| onFilesChange | (files: File[]) => void | — | 用户选定或拖入文件后立即触发的回调,常用于业务层自主控制上传逻辑。 |
| onUpload | (files: File[]) => void | Promise<void> | — | 选择文件后自动执行的上传函数。若返回 Promise,组件内部会自动管理 uploading/complete/error 状态。 |
| disabled | boolean | false | 是否禁用拖拽与文件选取交互。 |
| className | string | — | 应用于外层拖拽容器的额外 CSS 类名。 |
UploadFileList 与 UploadFileItem
用于展示已选择文件的列表。UploadFileList 内的条目新增时自下而上滑入,移除时淡出,其余条目平滑补位;上传中的条目底部带有随 progress 平滑推进的进度条。
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| name | string | — | 文件名,超出宽度时截断。 |
| size | number | string | — | 文件大小。传入数字(字节)时自动格式化为 B / KB / MB。 |
| status | "idle" | "uploading" | "complete" | "error" | "complete" | 该文件的上传状态,决定右侧状态图标与副标题文案,状态切换时图标缩放交替。 |
| progress | number | 0 | 上传中的进度(0 ~ 100),驱动条目底部的进度条。 |
| description | React.ReactNode | — | 替换副标题的说明或错误文案,例如“文件超过 20 MB”。 |
| icon | React.ReactNode | — | 左侧的文件图标或缩略图,默认使用通用文件图标。 |
| onRemove | () => void | — | 传入后显示移除按钮,点击时调用。 |
保持条目为列表的直接子元素
UploadFileList 依赖 AnimatePresence 追踪条目的进出,请为每个 UploadFileItem 提供稳定的 key,并将其作为列表的直接子元素渲染。
事件 Events
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| onFilesChange | (files: File[]) => void | — | 当用户通过系统文件对话框或拖放放入合法文件时调用,参数为 File 数组。 |
| onUpload | (files: File[]) => void | Promise<void> | — | 上传任务执行函数。可通过 async/await 处理上传流程,组件会据此更新图标动效与文字状态。 |
使用场景与设计规范
Upload 适用于附件上传、个人资料头像设定、大型文件与素材交付等场景。
- 拖拽热区反馈(Drop Zone):当文件悬停在组件上方时,边框高亮并伴随轻微的弹簧放大效果;松开鼠标后自动解析文件列表。
- 状态流转与微动效:
idle:常态虚线框与云上传图标。uploading:平滑旋转的加载指示器与进度条。complete:绿色实心打钩徽章与弹性缩放完成动画。error:红色错误提示并支持点击重新尝试。
- 安全与校验双重防护:前台
accept仅用于便利用户筛选,服务端必须对上传的实际二进制流(Magic Number)进行严格的类型、安全扫描与大小校验。
场景示例
用户头像即时预览上传
配置 size="compact" 并限制图片类型,在客户端生成 ObjectURL 实现无缝头像裁剪预览:
Loading…
进度条百分比反馈
配合 progress 属性实时渲染动态进度条,适合大文件切片上传或耗时数据包解析:
Loading…
多文件批量拖拽清单
开启 multiple 支持多文档同时拖入,并用 UploadFileList 展示每个文件独立的上传进度。超过大小限制的文件直接标记为失败;移除条目时其余文件平滑补位:
Loading…
状态与尺寸矩阵
直观对比 default 与 compact 尺寸在不同状态下的表现:
Loading…
无障碍与交互 Accessibility
- 语义化结构:基于原生的
<input type="file">隐藏渲染并使用<label>进行包裹,天然支持全键盘 Tab 聚焦与 Enter/Space 打开系统原生文件对话框。 - 动态状态播报:辅助描述区域包含
aria-live="polite",状态切换(如从“正在上传”变为“上传完成”)时屏幕阅读器会自动进行无障碍播报。 - 动效降级:图标翻转、旋转循环与呼吸动效在检测到系统的
prefers-reduced-motion设置时自动降级为无动画静态呈现。