wui
组件

进度条 Progress

细腻克制的线性与环形进度指示器,支持确定性百分比与不确定动态状态。

第三方依赖 · radix-ui第三方依赖 · class-variance-authority第三方依赖 · motion

基础用法

最基础的进度条用法。传入 value 展示任务当前的完成百分比;数值变化时进度以无回弹的弹簧曲线平滑过渡:

Loading…

安装与引入

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

pnpm dlx @wui-design/cli@latest add @wui/progress
安装基础依赖与 Radix 原语
pnpm add radix-ui motion class-variance-authority clsx tailwind-merge
复制组件源码到 components/ui/progress.tsx
components/ui/progress.tsx
"use client"

import * as React from "react"
import { Progress as ProgressPrimitive } from "radix-ui"
import {
  motion,
  useReducedMotion,
  useSpring,
  useTransform,
  type MotionValue,
} from "motion/react"
import { cva } from "class-variance-authority"

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

const progressVariants = cva("relative", {
  variants: {
    variant: {
      linear: "w-full overflow-hidden rounded-full bg-muted",
      circular: "inline-flex shrink-0 items-center justify-center",
    },
    size: {
      sm: "",
      default: "",
      lg: "",
    },
  },
  compoundVariants: [
    { variant: "linear", size: "sm", className: "h-1" },
    { variant: "linear", size: "default", className: "h-1.5" },
    { variant: "linear", size: "lg", className: "h-2" },
    { variant: "circular", size: "sm", className: "size-8" },
    { variant: "circular", size: "default", className: "size-11" },
    { variant: "circular", size: "lg", className: "size-14" },
  ],
  defaultVariants: { variant: "linear", size: "default" },
})

const progressColor = {
  primary: "text-primary",
  blue: "text-info",
  success: "text-success",
  warning: "text-warning",
  destructive: "text-destructive",
} as const

const valueTextSize = {
  sm: "text-[9px]",
  default: "text-[11px]",
  lg: "text-xs",
} as const

/** Settles value changes quickly without overshooting past the real progress. */
const valueSpring = { stiffness: 220, damping: 32, mass: 0.8 }
const sweepEase = [0.65, 0, 0.35, 1] as const

export interface ProgressProps
  extends Omit<React.ComponentProps<typeof ProgressPrimitive.Root>, "color"> {
  /** Layout of the progress indicator. @default "linear" */
  variant?: "linear" | "circular"
  /** Thickness preset for linear progress and diameter preset for circular progress. @default "default" */
  size?: "sm" | "default" | "lg"
  /** Semantic accent used by the completed portion. @default "primary" */
  color?: keyof typeof progressColor
  /** Shows the current percentage inside a circular indicator. The number rolls with the bar. @default false */
  showValue?: boolean
  /** Extra classes applied to the completed portion. */
  indicatorClassName?: string
}

function useSmoothedPercentage(percentage: number) {
  const reduceMotion = useReducedMotion()
  const smoothed = useSpring(percentage, valueSpring)

  React.useEffect(() => {
    if (reduceMotion) smoothed.jump(percentage)
    else smoothed.set(percentage)
  }, [percentage, reduceMotion, smoothed])

  return smoothed
}

/** A linear or circular progress indicator with determinate and indeterminate states. */
function Progress({
  className,
  indicatorClassName,
  value,
  max = 100,
  variant = "linear",
  size = "default",
  color = "primary",
  showValue = false,
  ...props
}: ProgressProps) {
  const indeterminate = value == null
  const percentage = indeterminate
    ? 0
    : Math.min(100, Math.max(0, (value / max) * 100))
  const smoothed = useSmoothedPercentage(percentage)

  return (
    <ProgressPrimitive.Root
      data-slot="progress"
      data-size={size}
      data-variant={variant}
      data-color={color}
      className={cn(
        progressVariants({ variant, size }),
        progressColor[color],
        className
      )}
      value={value}
      max={max}
      {...props}
    >
      {variant === "circular" ? (
        <CircularIndicator
          size={size}
          indeterminate={indeterminate}
          smoothed={smoothed}
          showValue={showValue}
          className={indicatorClassName}
        />
      ) : (
        <LinearIndicator
          indeterminate={indeterminate}
          smoothed={smoothed}
          className={indicatorClassName}
        />
      )}
    </ProgressPrimitive.Root>
  )
}

function LinearIndicator({
  indeterminate,
  smoothed,
  className,
}: {
  indeterminate: boolean
  smoothed: MotionValue<number>
  className?: string
}) {
  const reduceMotion = useReducedMotion()
  const x = useTransform(smoothed, (latest) => `${latest - 100}%`)

  if (indeterminate) {
    return (
      <ProgressPrimitive.Indicator key="indeterminate" asChild>
        <motion.div
          data-slot="progress-indicator"
          className={cn(
            "absolute inset-y-0 left-0 w-2/5 rounded-full bg-current",
            className
          )}
          initial={{ x: reduceMotion ? "75%" : "-100%" }}
          animate={reduceMotion ? { opacity: 0.6 } : { x: "250%" }}
          transition={
            reduceMotion
              ? { duration: 0 }
              : {
                  duration: 1.35,
                  ease: sweepEase,
                  repeat: Infinity,
                  repeatDelay: 0.15,
                }
          }
        />
      </ProgressPrimitive.Indicator>
    )
  }

  return (
    <ProgressPrimitive.Indicator key="determinate" asChild>
      <motion.div
        data-slot="progress-indicator"
        className={cn("h-full w-full rounded-full bg-current", className)}
        style={{ x }}
      />
    </ProgressPrimitive.Indicator>
  )
}

function CircularIndicator({
  size,
  indeterminate,
  smoothed,
  showValue,
  className,
}: {
  size: NonNullable<ProgressProps["size"]>
  indeterminate: boolean
  smoothed: MotionValue<number>
  showValue: boolean
  className?: string
}) {
  const reduceMotion = useReducedMotion()
  const strokeWidth = size === "sm" ? 9 : size === "lg" ? 7 : 8
  const radius = 50 - strokeWidth / 2
  const circumference = 2 * Math.PI * radius
  const dashOffset = useTransform(
    smoothed,
    (latest) => circumference * (1 - latest / 100)
  )
  const label = useTransform(smoothed, (latest) => Math.round(latest))

  return (
    <>
      <svg
        data-slot="progress-ring"
        className={cn(
          "size-full -rotate-90",
          indeterminate &&
            "animate-spin [animation-duration:1.1s] motion-reduce:animate-none"
        )}
        viewBox="0 0 100 100"
        aria-hidden="true"
      >
        <circle
          cx="50"
          cy="50"
          r={radius}
          fill="none"
          stroke="currentColor"
          strokeOpacity="0.14"
          strokeWidth={strokeWidth}
        />
        <ProgressPrimitive.Indicator
          key={indeterminate ? "indeterminate" : "determinate"}
          asChild
        >
          {indeterminate ? (
            <motion.circle
              data-slot="progress-indicator"
              className={className}
              cx="50"
              cy="50"
              r={radius}
              fill="none"
              stroke="currentColor"
              strokeWidth={strokeWidth}
              strokeLinecap="round"
              strokeDasharray={circumference}
              initial={{ strokeDashoffset: circumference * 0.78 }}
              animate={
                reduceMotion
                  ? undefined
                  : {
                      strokeDashoffset: [
                        circumference * 0.78,
                        circumference * 0.3,
                        circumference * 0.78,
                      ],
                    }
              }
              transition={{
                duration: 1.6,
                ease: "easeInOut",
                repeat: Infinity,
              }}
            />
          ) : (
            <motion.circle
              data-slot="progress-indicator"
              className={className}
              cx="50"
              cy="50"
              r={radius}
              fill="none"
              stroke="currentColor"
              strokeWidth={strokeWidth}
              strokeLinecap="round"
              strokeDasharray={circumference}
              style={{ strokeDashoffset: dashOffset }}
            />
          )}
        </ProgressPrimitive.Indicator>
      </svg>
      {showValue && !indeterminate ? (
        <motion.span
          data-slot="progress-value"
          className={cn(
            "absolute font-semibold tabular-nums text-foreground",
            valueTextSize[size]
          )}
        >
          {label}
        </motion.span>
      ) : null}
    </>
  )
}

export { Progress, progressVariants }

属性 Props

Progress 支持以下核心配置属性:

属性类型默认值说明
valuenumber | null—当前进度值(0 至 max 之间)。传入 null 时呈现不确定加载状态(Indeterminate)。
maxnumber100进度条的最大量程基准值。
variant"linear" | "circular""linear"进度条的形态布局。linear 为水平线形条,circular 为矢量环形圆环。
size"sm" | "default" | "lg""default"尺寸规格。对于线性条控制轨道高度(sm: 4px, default: 6px, lg: 8px);对于环形控制直径。
color"primary" | "blue" | "success" | "warning" | "destructive""primary"已完成进度的语义色彩主题。
showValuebooleanfalse在环形进度(circular)中心是否渲染百分比数字文本,数字与圆环同步滚动变化。
indicatorClassNamestring—应用于进度指示条/圆弧的额外 CSS 类名。
classNamestring—应用于外层容器的额外 CSS 类名。

事件 Events

Progress 属于只读展示类状态组件,不直接派发自定义用户交互事件。数值由业务状态驱动更新,进度条会自动同步计算无障碍属性与动画补间:

属性类型默认值说明
onClick(event: React.MouseEvent<HTMLDivElement>) => void—在进度条容器上点击时触发的原生 DOM 事件。

使用场景与设计规范

Progress 用于明确告知用户某一异步任务的实际进展或资源占用情况。

  • 确定性(Determinate)vs 不确定性(Indeterminate):
    • 确定性进度(value={number}):适用于任务总耗时或总数据量已知(如已下载 35MB / 共 100MB)。
    • 不确定性进度(value={null}):适用于任务正在后台进行但无法预估具体剩余量(如正在建立安全加密握手),此时线性进度条以往返扫过的光带、环形进度以旋转并伸缩的弧线表达等待。
  • 色彩与语义阈值规范:
    • primary / blue:常规进行中的业务流程(如下载、更新)。
    • success:任务成功完成或系统健康度处于良好区间。
    • warning:资源使用量接近警戒水位(如存储占用超过 80%)。
    • destructive:流程发生错误截断或资源配额严重超限。
  • 形态选型建议:
    • 水平线性(Linear):适合页面顶部加载条、卡片列表项或文件上传列表。
    • 环形(Circular):适合仪表盘小部件、紧凑指标卡片或头像周围的状态环。

场景示例

语义色彩与环形形态

支持多种业务语义颜色配置,环形模式下可开启 showValue 展示中心百分比:

Loading…

尺寸规格对比

线性轨道与环形圆环均提供 sm、default 与 lg 三种尺寸:

Loading…

动态任务流(文件上传)

在实际业务流程中,通过状态驱动进度条增长,并在各阶段切换颜色与图标:

Loading…

不确定状态与阶段切换

任务无法预估耗时时传入 value={null},拿到总量后再切换为具体数值,完成时切换为 success 颜色:

Loading…

仪表盘资源监控看板

组合多个小型进度条监控服务器 CPU、存储与系统 SLA 健康度:

Loading…

无障碍与交互 Accessibility

  • ARIA 标准规范:底层基于 Radix UI 原语,根元素挂载 role="progressbar",并自动同步计算 aria-valuenow、aria-valuemin 与 aria-valuemax。
  • 平滑动画与动效降级:进度值由 motion 弹簧驱动,线性轨道与环形描边、中心数字保持同步;检测到 prefers-reduced-motion 时直接跳转到最新百分比,不确定状态也会停止循环动画。
  • 读屏器文案提示:建议在未展示明确可见标题时,为组件添加 aria-label(如 aria-label="数据同步进度")。