组件
骨架屏 Skeleton
在内容异步加载期间维持页面版式结构,降低累积布局偏移(CLS)与用户等待的不确定感。
基础用法
最基础的骨架屏用法。组合圆角块、圆形头像占位与多行文本占位:
Loading…
安装与引入
通过 CLI 自动添加组件,或手动复制源码至项目中:
pnpm dlx @wui-design/cli@latest add @wui/skeleton安装基础依赖与工具函数
pnpm add motion class-variance-authority clsx tailwind-merge复制组件源码到
components/ui/skeleton.tsx"use client"
import * as React from "react"
import { AnimatePresence, motion, useReducedMotion } from "motion/react"
import { cva } from "class-variance-authority"
import { cn } from "@/lib/utils"
const skeletonVariants = cva("shrink-0 bg-muted", {
variants: {
shape: {
default: "rounded-md",
circle: "rounded-full",
text: "h-4 rounded-sm",
},
animation: {
pulse: "animate-pulse motion-reduce:animate-none",
shimmer: "relative isolate overflow-hidden",
none: "",
},
},
defaultVariants: {
shape: "default",
animation: "pulse",
},
})
type SkeletonAnimation = "pulse" | "shimmer" | "none"
export interface SkeletonProps extends React.ComponentProps<"div"> {
/** Corner treatment for the placeholder. @default "default" */
shape?: "default" | "circle" | "text"
/** Loading animation. `shimmer` sweeps a soft highlight across the block. @default "pulse" */
animation?: SkeletonAnimation
}
/**
* The shimmer reuses tw-animate-css's `enter` keyframe: the band starts one
* track to the right of the block and travels from -200% back to rest, which
* reads as a single left-to-right sweep without a custom global keyframe.
*/
const shimmerClassName =
"pointer-events-none absolute inset-y-0 left-full w-full bg-linear-to-r from-transparent via-foreground/[0.06] to-transparent animate-in slide-in-from-left-[200%] repeat-infinite duration-[1600ms] ease-[cubic-bezier(0.4,0,0.2,1)] motion-reduce:hidden"
/** A neutral placeholder that preserves layout while content is loading. */
function Skeleton({
className,
shape = "default",
animation = "pulse",
children,
...props
}: SkeletonProps) {
return (
<div
data-slot="skeleton"
data-shape={shape}
data-animation={animation}
aria-hidden="true"
className={cn(skeletonVariants({ shape, animation }), className)}
{...props}
>
{animation === "shimmer" ? (
<span data-slot="skeleton-shimmer" className={shimmerClassName} />
) : null}
{children}
</div>
)
}
export interface SkeletonTextProps extends React.ComponentProps<"div"> {
/** Number of text rows. @default 3 */
lines?: number
/** Width of the final row. @default "70%" */
lastLineWidth?: React.CSSProperties["width"]
/** Loading animation shared by every row. @default "pulse" */
animation?: SkeletonAnimation
}
/** Generates a compact stack of text-shaped skeleton rows. */
function SkeletonText({
className,
lines = 3,
lastLineWidth = "70%",
animation = "pulse",
...props
}: SkeletonTextProps) {
return (
<div
data-slot="skeleton-text"
aria-hidden="true"
className={cn("grid w-full gap-2", className)}
{...props}
>
{Array.from({ length: lines }, (_, index) => (
<Skeleton
key={index}
shape="text"
animation={animation}
className="w-full"
style={index === lines - 1 ? { width: lastLineWidth } : undefined}
/>
))}
</div>
)
}
export interface SkeletonSwapProps
extends Omit<React.ComponentProps<"div">, "children"> {
/** Shows `fallback` while true and crossfades to `children` once false. */
loading: boolean
/** Placeholder rendered while loading, usually composed from Skeleton. */
fallback: React.ReactNode
/** Loaded content revealed with a short fade and rise. */
children: React.ReactNode
}
const revealEase = [0.22, 1, 0.36, 1] as const
/** Swaps a skeleton placeholder for the loaded content with a calm crossfade. */
function SkeletonSwap({
loading,
fallback,
children,
className,
...props
}: SkeletonSwapProps) {
const reduceMotion = useReducedMotion()
return (
<div
data-slot="skeleton-swap"
data-loading={loading || undefined}
aria-busy={loading}
className={className}
{...props}
>
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={loading ? "fallback" : "content"}
initial={
reduceMotion
? { opacity: 0 }
: loading
? { opacity: 0 }
: { opacity: 0, y: 4, filter: "blur(2px)" }
}
animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
exit={{ opacity: 0 }}
transition={{
duration: reduceMotion ? 0 : loading ? 0.15 : 0.28,
ease: revealEase,
}}
>
{loading ? fallback : children}
</motion.div>
</AnimatePresence>
</div>
)
}
export { Skeleton, SkeletonSwap, SkeletonText, skeletonVariants }
属性 Props
Skeleton
单体骨架占位块:
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| shape | "default" | "circle" | "text" | "default" | 占位块的几何轮廓形态。default 具有圆角,circle 用于头像与图标,text 用于单行文本。 |
| animation | "pulse" | "shimmer" | "none" | "pulse" | 加载动画。pulse 为整体明暗呼吸,shimmer 为一道柔和高光自左向右扫过,none 为静态纯色。开启“减少动态效果”时均停止。 |
| className | string | — | 应用于骨架屏元素的额外 CSS 类名(用于控制宽高与对齐)。 |
SkeletonText
多行文本占位组件,快速生成模拟真实段落排版的文本骨架组:
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| lines | number | 3 | 渲染的文本占位行数。 |
| lastLineWidth | React.CSSProperties['width'] | "70%" | 最后一行文本占位的宽度比例或长度,用于逼真还原段落自然的末行缩进。 |
| animation | "pulse" | "shimmer" | "none" | "pulse" | 应用于所有文本行的加载动画。 |
| className | string | — | 应用于文本骨架外层容器的额外 CSS 类名。 |
SkeletonSwap
在占位与真实内容之间切换:加载中渲染 fallback,加载完成后以短暂的淡入上浮呈现 children,并自动在容器上同步 aria-busy。继承原生 <div> 属性:
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| loading | boolean | — | 为 true 时显示 fallback,变为 false 时切换到 children。 |
| fallback | React.ReactNode | — | 加载期间渲染的占位内容,通常由 Skeleton 组合而成。 |
| children | React.ReactNode | — | 加载完成后展示的真实内容。 |
事件 Events
骨架屏属于纯视觉占位元素,不响应任何交互事件,并默认挂载 aria-hidden="true" 对辅助技术隐藏内部 DOM。父级加载容器应统一管理无障碍加载属性:
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| aria-busy | boolean | — | 建议在骨架屏的外部父容器上声明 aria-busy='true',通知辅助技术当前区域正在加载。 |
| aria-label | string | — | 在父容器上提供通报文案(如 aria-label='正在加载个人资料')。 |
使用场景与设计规范
骨架屏是缓解首屏白屏焦虑和防止布局剧烈跳动的核心利器。
- 布局保真度适中:骨架屏的核心目标是预占空间与大致形态(如卡片宽高比、头像位置、标题与段落行数)。切忌过细刻画(不要为图标的每个微小分支都画独立骨架),保持克制干净。
- 防止累积布局偏移(CLS):图片占位需显式声明
aspect-ratio(如aspect-[16/9]或aspect-square),确保真实图像在网络载入完毕后不会将页面下方内容突兀下推。 - 与 Spin 的取舍:
- 初次渲染或大块区域加载:使用
Skeleton。 - 用户操作后局部数据重载或刷新:使用
Spin遮罩或局部按钮加载态,避免整块内容被瞬间替换为骨架屏导致画面闪烁。
- 初次渲染或大块区域加载:使用
场景示例
动画类型
pulse 适合零散的小块占位;shimmer 的高光扫过方向一致,适合整页或大面积版面;none 用于嵌套在其他加载指示内的静态占位:
Loading…
占位到内容的过渡
使用 SkeletonSwap 在数据返回后平滑替换骨架,避免内容瞬间跳出。点击“重新加载”可重复查看过渡:
Loading…
图文资讯卡片布局
在资讯流、博客文章或媒体库中,模拟左图右文或上图下文的内容加载:
Loading…
数据表格加载骨架
在管理后台异步获取表格数据时,预先渲染表头与各列对应的数据占位行:
Loading…
复杂数据分析仪表盘
在监控看板中,为指标卡片、趋势图表与侧边明细区域同时搭建完整的骨架版面:
Loading…
无障碍与交互 Accessibility
- 自动对读屏器隐藏内部细节:
Skeleton与SkeletonText均内置aria-hidden="true",防止屏幕阅读器逐个读出无意义的“空白块”或“未命名图形”。 - 父级区域语义提示:推荐在承载骨架屏的外部容器上设置
aria-busy="true"并配合aria-label="正在加载...",使无障碍设备能准确理解当前状态。 - 系统动效偏好适配:
pulse动画类内置motion-reduce:animate-none。当用户在操作系统中开启了“减少动态效果(prefers-reduced-motion)”时,闪烁脉冲动画会自动停用,改为静态灰色色块。