wui
组件

加载中 Spin

轻量且支持无障碍降级的动态加载指示器,支持独立展示、局部内容遮罩与全屏阻塞。

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

基础用法

最基础的加载状态用法。既可以作为独立图标指示器使用,也可以包裹任何已有内容,在异步处理时呈现半透明遮罩并阻止重复操作:

Loading…

安装与引入

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

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

import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { AnimatePresence, motion, useReducedMotion } from "motion/react"

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

const spinIndicatorVariants = cva("inline-flex shrink-0 text-primary", {
  variants: {
    size: {
      sm: "size-4",
      default: "size-6",
      lg: "size-9",
    },
  },
  defaultVariants: {
    size: "default",
  },
})

export type SpinVariant = "ring" | "dots"

export interface SpinIndicatorProps
  extends
    React.ComponentProps<typeof motion.svg>,
    VariantProps<typeof spinIndicatorVariants> {
  /** Indicator dimensions. @default "default" */
  size?: "sm" | "default" | "lg"
  /** Indicator style: a rotating arc that breathes, or three pulsing dots. @default "ring" */
  variant?: SpinVariant
}

/** The animated mark used by Spin. */
function SpinIndicator({
  className,
  size = "default",
  variant = "ring",
  ...props
}: SpinIndicatorProps) {
  const reduceMotion = useReducedMotion()

  if (variant === "dots") {
    return (
      <motion.svg
        data-slot="spin-indicator"
        data-variant="dots"
        viewBox="0 0 24 24"
        fill="currentColor"
        aria-hidden="true"
        className={cn(spinIndicatorVariants({ size }), className)}
        {...props}
      >
        {[5, 12, 19].map((cx, index) => (
          <motion.circle
            key={cx}
            cx={cx}
            cy="12"
            r="2.4"
            style={{ transformBox: "fill-box", transformOrigin: "center" }}
            initial={{ opacity: 0.35, scale: 0.75 }}
            animate={
              reduceMotion
                ? { opacity: 0.7, scale: 1 }
                : { opacity: [0.35, 1, 0.35], scale: [0.75, 1, 0.75] }
            }
            transition={
              reduceMotion
                ? { duration: 0 }
                : {
                    duration: 1,
                    ease: "easeInOut",
                    repeat: Infinity,
                    delay: index * 0.16,
                  }
            }
          />
        ))}
      </motion.svg>
    )
  }

  return (
    <motion.svg
      data-slot="spin-indicator"
      data-variant="ring"
      viewBox="0 0 24 24"
      fill="none"
      aria-hidden="true"
      className={cn(spinIndicatorVariants({ size }), className)}
      animate={reduceMotion ? undefined : { rotate: 360 }}
      transition={
        reduceMotion
          ? undefined
          : { duration: 0.9, ease: "linear", repeat: Infinity }
      }
      {...props}
    >
      <circle
        cx="12"
        cy="12"
        r="8.5"
        stroke="currentColor"
        strokeWidth="2.25"
        opacity="0.16"
      />
      {/* The arc grows and shrinks while the whole mark rotates, so the motion reads as progress rather than a fixed wheel. */}
      <motion.circle
        cx="12"
        cy="12"
        r="8.5"
        stroke="currentColor"
        strokeWidth="2.25"
        strokeLinecap="round"
        initial={{ pathLength: 0.25, rotate: 0 }}
        animate={
          reduceMotion
            ? { pathLength: 0.25 }
            : { pathLength: [0.12, 0.62, 0.12], rotate: [0, 120, 360] }
        }
        style={{ transformBox: "fill-box", transformOrigin: "center" }}
        transition={
          reduceMotion
            ? { duration: 0 }
            : { duration: 1.6, ease: "easeInOut", repeat: Infinity }
        }
      />
    </motion.svg>
  )
}

export interface SpinProps extends React.ComponentProps<"div"> {
  /** Whether the loading state is visible. @default true */
  spinning?: boolean
  /** Indicator dimensions. @default "default" */
  size?: "sm" | "default" | "lg"
  /** Accessible loading message shown beside the indicator. */
  label?: React.ReactNode
  /** Replace the default orbital indicator. */
  indicator?: React.ReactNode
  /** Built-in indicator style. Ignored when `indicator` is provided. @default "ring" */
  variant?: SpinVariant
  /** Wait before showing the indicator to avoid flashes for fast operations. @default 0 */
  delay?: number
  /** Cover the viewport instead of rendering in document flow. @default false */
  fullscreen?: boolean
}

/** A reduced-motion aware loading indicator for inline, nested, and fullscreen states. */
function Spin({
  className,
  children,
  spinning = true,
  size = "default",
  label,
  indicator,
  variant = "ring",
  delay = 0,
  fullscreen = false,
  ...props
}: SpinProps) {
  const reduceMotion = useReducedMotion()
  const [visible, setVisible] = React.useState(spinning && delay <= 0)

  React.useEffect(() => {
    if (!spinning) {
      setVisible(false)
      return
    }
    if (delay <= 0) {
      setVisible(true)
      return
    }
    const timer = window.setTimeout(() => setVisible(true), delay)
    return () => window.clearTimeout(timer)
  }, [delay, spinning])

  const status = (
    <motion.div
      role="status"
      aria-live="polite"
      data-slot="spin-status"
      className="text-muted-foreground flex flex-col items-center justify-center gap-2 text-center text-sm"
      initial={reduceMotion ? false : { opacity: 0, scale: 0.94, y: 2 }}
      animate={{ opacity: 1, scale: 1, y: 0 }}
      exit={reduceMotion ? { opacity: 0 } : { opacity: 0, scale: 0.96 }}
      transition={
        reduceMotion
          ? { duration: 0 }
          : { type: "spring", stiffness: 420, damping: 32, mass: 0.65 }
      }
    >
      {indicator ?? <SpinIndicator size={size} variant={variant} />}
      {label ? <span>{label}</span> : <span className="sr-only">加载中</span>}
    </motion.div>
  )

  if (fullscreen) {
    return (
      <AnimatePresence>
        {visible ? (
          <motion.div
            data-slot="spin"
            className={cn(
              "bg-background/90 fixed inset-0 z-50 flex items-center justify-center",
              className
            )}
            initial={{ opacity: 0 }}
            animate={{ opacity: 1 }}
            exit={{ opacity: 0 }}
            transition={{ duration: reduceMotion ? 0 : 0.18, ease: "easeOut" }}
            {...(props as unknown as React.ComponentProps<typeof motion.div>)}
          >
            {status}
          </motion.div>
        ) : null}
      </AnimatePresence>
    )
  }

  if (children === undefined) {
    return (
      <div data-slot="spin" className={cn("inline-flex", className)} {...props}>
        <AnimatePresence>{visible ? status : null}</AnimatePresence>
      </div>
    )
  }

  return (
    <div
      data-slot="spin"
      aria-busy={visible}
      className={cn("relative", className)}
      {...props}
    >
      <div
        data-slot="spin-content"
        className={cn(
          "transition-opacity duration-300 ease-out motion-reduce:transition-none",
          visible && "pointer-events-none select-none opacity-40"
        )}
      >
        {children}
      </div>
      <AnimatePresence>
        {visible ? (
          <motion.div
            data-slot="spin-overlay"
            className="absolute inset-0 z-10 flex items-center justify-center"
            initial={{ opacity: 0 }}
            animate={{ opacity: 1 }}
            exit={{ opacity: 0 }}
            transition={{ duration: reduceMotion ? 0 : 0.16, ease: "easeOut" }}
          >
            {status}
          </motion.div>
        ) : null}
      </AnimatePresence>
    </div>
  )
}

export { Spin, SpinIndicator, spinIndicatorVariants }

属性 Props

Spin

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

属性类型默认值说明
spinningbooleantrue是否展示加载状态。当包裹子元素时,为 true 会渲染遮罩层并禁用子元素交互。
size"sm" | "default" | "lg""default"内置轨道指示器的物理尺寸大小(sm: 16px, default: 24px, lg: 36px)。
labelReact.ReactNode—加载指示器下方或后方附带的提示文本(如“正在保存…”)。
indicatorReact.ReactNode—自定义指示器图标,传入后将完全替代默认的轨道旋转 SVG 图标。
variant"ring" | "dots""ring"内置指示器样式;传入 `indicator` 时忽略。
delaynumber0延迟显示加载状态的毫秒数(防闪烁)。若异步操作在 delay 毫秒内完成,则不展示加载层。
fullscreenbooleanfalse是否渲染为覆盖整个视口的固定定位全屏遮罩。
childrenReact.ReactNode—被包裹的局部容器内容。如果不传,则以行内块形式仅展示 Spin 图标本身。
classNamestring—应用于外层容器的额外 CSS 类名。

SpinIndicator

底层旋转指示器组件,可单独用于按钮或行内状态:

属性类型默认值说明
variant"ring" | "dots""ring"指示器样式。"ring" 为旋转中伸缩的圆弧;"dots" 为依次起伏的三个圆点,适合行内与按钮内的轻量状态。
size"sm" | "default" | "lg""default"指示器的物理尺寸大小。
classNamestring—应用于 SVG 元素的额外 CSS 类名。

事件 Events

Spin 属于状态指示类组件,不直接派发自定义业务事件。加载层处于激活状态时,内部包裹的子元素会自动挂载 pointer-events-none 与 select-none,拦截所有鼠标与触摸事件,防止用户重复提交:

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

使用场景与设计规范

合理的加载反馈能极大缓解用户的等待焦虑,但必须根据不同耗时与场景精准选用:

  • 组件选型对比:
    • Spin:适用于用户主动触发的短时局部操作(如点击表格“刷新”、表单提交保存、卡片重新计算)。保留了原有内容框架并加以遮罩。
    • Skeleton:适用于首屏初次加载或页面路由切换。用户尚未看到过内容时,骨架屏能够提前展现视觉版式,降低布局跳动感。
    • Progress:适用于已知进度百分比的长耗时任务(如大文件上传、固件刷写、数据导入)。
  • 防闪烁延迟(Delay):对于接口响应通常在 200~300ms 之间的快速请求,强烈建议设置 delay={300},避免指示器一闪而过造成的视觉干扰。
  • 克制使用全屏加载:全屏遮罩(fullscreen)会强行剥夺用户的所有交互自由,只推荐在整站环境切换、关键发布、清库等极少数全局阻塞操作中使用。

场景示例

尺寸对比

提供 sm(16px)、default(24px)和 lg(36px)三种尺寸规格:

Loading…

防闪烁延迟保护

通过 delay 属性过滤瞬时微小请求,避免画面频繁闪烁跳动:

Loading…

指示器样式与自定义

内置 ring(伸缩圆弧)与 dots(起伏圆点)两种样式;也可以通过 indicator 传入业务专属的图标:

Loading…

全屏遮罩模式

在需要全局阻塞的重度流程中使用 fullscreen:

Loading…

无障碍与交互 Accessibility

  • 状态通报:指示器文本区域自动绑定 role="status" 和 aria-live="polite",屏幕阅读器在进入加载状态时会主动向视障用户通报加载文字;若未提供 label,会自动提供隐藏的 <span className="sr-only">加载中</span> 兜底。
  • 忙碌指示:被包裹的容器会自动根据 visible 状态同步 aria-busy="true" 属性。
  • 动效降级适配:内部集成 motion/react 的 useReducedMotion()。当操作系统开启“减少动态效果(Reduce Motion)”时,SVG 旋转和弹性伸缩动画将完全停用,直接静态呈现,避免造成前庭觉不适。