组件
加载中 Spin
用轻量、可降级的动态指示器表达等待状态。
基础示例
Loading…
pnpm dlx wui@latest add @wui/spin"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 interface SpinIndicatorProps
extends
React.ComponentProps<typeof motion.svg>,
VariantProps<typeof spinIndicatorVariants> {
/** Indicator dimensions. @default "default" */
size?: "sm" | "default" | "lg"
}
/** The animated orbital mark used by Spin. */
function SpinIndicator({
className,
size = "default",
...props
}: SpinIndicatorProps) {
const reduceMotion = useReducedMotion()
return (
<motion.svg
data-slot="spin-indicator"
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.82, ease: "linear", repeat: Infinity }
}
{...props}
>
<circle
cx="12"
cy="12"
r="8.5"
stroke="currentColor"
strokeWidth="2.25"
opacity="0.16"
/>
<path
d="M12 3.5a8.5 8.5 0 0 1 8.5 8.5"
stroke="currentColor"
strokeWidth="2.25"
strokeLinecap="round"
/>
</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
/** 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,
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} />}
{label ? <span>{label}</span> : <span className="sr-only">Loading</span>}
</motion.div>
)
if (fullscreen) {
return (
<AnimatePresence>
{visible ? (
<motion.div
data-slot="spin"
className={cn(
"bg-background/86 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-200 motion-reduce:transition-none",
visible && "pointer-events-none select-none opacity-45"
)}
>
{children}
</div>
<AnimatePresence>
{visible ? (
<motion.div
data-slot="spin-overlay"
className="bg-background/55 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, spinIndicatorVariants }
组件作用
Spin 适合用户已经触发操作、但结果尚未返回的短时等待。独立使用时呈现状态指示器,包裹内容时会保留原布局并覆盖交互;首屏骨架和结构化占位仍应使用 Skeleton。
组件属性
| Prop | Type | Default | Description |
|---|---|---|---|
| spinning | boolean | true | Whether the loading state is visible. |
| size | "sm" | "default" | "lg" | default | Indicator dimensions. |
| label | ReactNode | — | Accessible loading message shown beside the indicator. |
| indicator | ReactNode | — | Replace the default orbital indicator. |
| delay | number | 0 | Wait before showing the indicator to avoid flashes for fast operations. |
| fullscreen | boolean | false | Cover the viewport instead of rendering in document flow. |
事件
组件不产生业务事件,由 spinning 控制显示。delay 可过滤瞬时请求造成的闪烁;系统开启“减少动态效果”后,旋转与弹性过渡会立即停用,但加载语义仍然保留。
扩展使用
传入 indicator 可替换默认轨道;fullscreen 用于会阻塞整页的关键切换。普通局部请求优先使用内容遮罩,避免全屏加载打断上下文。