wui
组件

骨架屏 Skeleton

在内容异步加载期间维持页面版式结构,降低累积布局偏移(CLS)与用户等待的不确定感。

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

基础用法

最基础的骨架屏用法。组合圆角块、圆形头像占位与多行文本占位:

Loading…

安装与引入

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

pnpm dlx @wui-design/cli@latest add @wui/skeleton
安装基础依赖与工具函数
pnpm add motion class-variance-authority clsx tailwind-merge
复制组件源码到 components/ui/skeleton.tsx
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 为静态纯色。开启“减少动态效果”时均停止。
classNamestring—应用于骨架屏元素的额外 CSS 类名(用于控制宽高与对齐)。

SkeletonText

多行文本占位组件,快速生成模拟真实段落排版的文本骨架组:

属性类型默认值说明
linesnumber3渲染的文本占位行数。
lastLineWidthReact.CSSProperties['width']"70%"最后一行文本占位的宽度比例或长度,用于逼真还原段落自然的末行缩进。
animation"pulse" | "shimmer" | "none""pulse"应用于所有文本行的加载动画。
classNamestring—应用于文本骨架外层容器的额外 CSS 类名。

SkeletonSwap

在占位与真实内容之间切换:加载中渲染 fallback,加载完成后以短暂的淡入上浮呈现 children,并自动在容器上同步 aria-busy。继承原生 <div> 属性:

属性类型默认值说明
loadingboolean—为 true 时显示 fallback,变为 false 时切换到 children。
fallbackReact.ReactNode—加载期间渲染的占位内容,通常由 Skeleton 组合而成。
childrenReact.ReactNode—加载完成后展示的真实内容。

事件 Events

骨架屏属于纯视觉占位元素,不响应任何交互事件,并默认挂载 aria-hidden="true" 对辅助技术隐藏内部 DOM。父级加载容器应统一管理无障碍加载属性:

属性类型默认值说明
aria-busyboolean—建议在骨架屏的外部父容器上声明 aria-busy='true',通知辅助技术当前区域正在加载。
aria-labelstring—在父容器上提供通报文案(如 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)”时,闪烁脉冲动画会自动停用,改为静态灰色色块。