wui
组件

上传 Upload

支持点击选取、文件拖拽放置、上传进度百分比展示与状态生命周期反馈的文件上传控件。

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

基础用法

最简单的文件上传用法。点击区域选择文件,或直接将文件从桌面拖拽到虚线框内:

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
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 属性:

属性类型默认值说明
labelstring"拖拽文件到此处,或点击选择"拖拽放置区域内的主标题文案引导。
descriptionstring—主标题下方的副标题或文件格式、大小说明文案。
status"idle" | "uploading" | "complete" | "error""idle"受控模式下的上传生命周期状态。
progressnumber0当前上传进度的百分比数值(0 ~ 100)。大于 0 时渲染确定进度条,为 0 时呈现不确定流动波纹动效。
size"default" | "compact""default"上传拖拽区域的物理尺寸密度。
multiplebooleanfalse是否允许一次性选择或拖放多个文件。
acceptstring—文件选择器过滤的文件类型(如 `'image/*'`、`'.pdf,.docx'`、`'application/zip'`)。
onFilesChange(files: File[]) => void—用户选定或拖入文件后立即触发的回调,常用于业务层自主控制上传逻辑。
onUpload(files: File[]) => void | Promise<void>—选择文件后自动执行的上传函数。若返回 Promise,组件内部会自动管理 uploading/complete/error 状态。
disabledbooleanfalse是否禁用拖拽与文件选取交互。
classNamestring—应用于外层拖拽容器的额外 CSS 类名。

UploadFileList 与 UploadFileItem

用于展示已选择文件的列表。UploadFileList 内的条目新增时自下而上滑入,移除时淡出,其余条目平滑补位;上传中的条目底部带有随 progress 平滑推进的进度条。

属性类型默认值说明
namestring—文件名,超出宽度时截断。
sizenumber | string—文件大小。传入数字(字节)时自动格式化为 B / KB / MB。
status"idle" | "uploading" | "complete" | "error""complete"该文件的上传状态,决定右侧状态图标与副标题文案,状态切换时图标缩放交替。
progressnumber0上传中的进度(0 ~ 100),驱动条目底部的进度条。
descriptionReact.ReactNode—替换副标题的说明或错误文案,例如“文件超过 20 MB”。
iconReact.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 设置时自动降级为无动画静态呈现。