组件
加载中 Spin
轻量且支持无障碍降级的动态加载指示器,支持独立展示、局部内容遮罩与全屏阻塞。
基础用法
最基础的加载状态用法。既可以作为独立图标指示器使用,也可以包裹任何已有内容,在异步处理时呈现半透明遮罩并阻止重复操作:
Loading…
安装与引入
通过 CLI 自动添加组件,或手动复制源码至项目中:
pnpm dlx @wui-design/cli@latest add @wui/spin安装基础依赖与动效库
pnpm add motion class-variance-authority clsx tailwind-merge复制组件源码到
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 支持以下核心配置属性:
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| spinning | boolean | true | 是否展示加载状态。当包裹子元素时,为 true 会渲染遮罩层并禁用子元素交互。 |
| size | "sm" | "default" | "lg" | "default" | 内置轨道指示器的物理尺寸大小(sm: 16px, default: 24px, lg: 36px)。 |
| label | React.ReactNode | — | 加载指示器下方或后方附带的提示文本(如“正在保存…”)。 |
| indicator | React.ReactNode | — | 自定义指示器图标,传入后将完全替代默认的轨道旋转 SVG 图标。 |
| variant | "ring" | "dots" | "ring" | 内置指示器样式;传入 `indicator` 时忽略。 |
| delay | number | 0 | 延迟显示加载状态的毫秒数(防闪烁)。若异步操作在 delay 毫秒内完成,则不展示加载层。 |
| fullscreen | boolean | false | 是否渲染为覆盖整个视口的固定定位全屏遮罩。 |
| children | React.ReactNode | — | 被包裹的局部容器内容。如果不传,则以行内块形式仅展示 Spin 图标本身。 |
| className | string | — | 应用于外层容器的额外 CSS 类名。 |
SpinIndicator
底层旋转指示器组件,可单独用于按钮或行内状态:
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| variant | "ring" | "dots" | "ring" | 指示器样式。"ring" 为旋转中伸缩的圆弧;"dots" 为依次起伏的三个圆点,适合行内与按钮内的轻量状态。 |
| size | "sm" | "default" | "lg" | "default" | 指示器的物理尺寸大小。 |
| className | string | — | 应用于 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 旋转和弹性伸缩动画将完全停用,直接静态呈现,避免造成前庭觉不适。