wui
组件

空状态 Empty State

在页面或模块无数据、无搜索结果或未配置时,清晰解释现状并引导用户开展下一步动作。

第三方依赖 · class-variance-authority

基础用法

最基础的空状态用法。组合插画或图标、标题、解释说明文本以及主/次操作按钮:

Loading…

安装与引入

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

pnpm dlx @wui-design/cli@latest add @wui/empty-state
安装基础依赖与工具函数
pnpm add class-variance-authority clsx tailwind-merge
复制组件源码到 components/ui/empty-state.tsx
components/ui/empty-state.tsx
import * as React from "react"
import { cva } from "class-variance-authority"

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

const emptyStateVariants = cva(
  "flex w-full flex-col items-center justify-center text-center",
  {
    variants: {
      size: {
        sm: "min-h-40 px-5 py-8",
        default: "min-h-64 px-6 py-12",
        lg: "min-h-80 px-8 py-16",
      },
    },
    defaultVariants: { size: "default" },
  }
)

const emptyStateClassicIllustrations = [
  "nothing-here-no-image",
  "under-construction-maintenance",
  "no-files-found",
  "empty-folder",
  "add-files",
  "search-folder",
  "add-media",
  "xls-file",
  "zip-file",
  "file-not-found",
  "search",
  "file-broken-or-not-found",
  "general-files",
  "search-general",
  "content-unavailable",
  "add-photo",
  "add-photos",
  "search-photos-files",
  "task-done",
  "no-content-general",
  "no-reports",
  "add-dashboard",
  "no-files",
  "upload-file",
  "upload-general",
  "messages-add-message",
  "no-messages",
  "cards",
  "add-card",
  "card-expired",
  "card-rejected",
  "no-credits",
  "no-tasks",
  "add-task",
  "no-appointments",
  "no-location",
  "no-address",
  "empty-inbox",
  "add-to-inbox",
  "email-sent",
  "empty-inbox-2",
  "check-your-inbox",
  "no-emails",
  "add-to-group",
  "add-user",
  "search-user",
  "add-user-2",
  "users",
  "something-went-wrong",
  "404-page-not-found",
  "nothing-here",
  "nothing-here-2",
  "no-products",
  "no-friends",
  "trash-empty",
  "empty-box",
  "add-product",
  "no-notifications",
  "launch-app",
  "add-alarm",
  "no-alarms",
  "youre-awesome",
  "no-vacation",
  "empty-cart",
  "connection-lost",
] as const

const emptyStateColorIllustrations = [
  "add-email",
  "allow-notifications",
  "app-locked",
  "camera-access",
  "email-sent",
  "expired-alt",
  "expired",
  "microphone-access",
  "no-plan-chosen",
  "payment",
  "phone-numbers",
  "search",
  "send-email",
  "settings",
  "resource-1",
  "resource-2",
  "resource-3",
  "resource-4",
  "resource-5",
  "resource-6",
] as const

const emptyStateIllustrations = {
  gradient: emptyStateClassicIllustrations,
  flat: emptyStateClassicIllustrations,
  color: emptyStateColorIllustrations,
} as const

export type EmptyStateClassicIllustration =
  (typeof emptyStateClassicIllustrations)[number]
export type EmptyStateColorIllustration =
  (typeof emptyStateColorIllustrations)[number]
export type EmptyStateIllustrationVariant = keyof typeof emptyStateIllustrations

type EmptyStateIllustrationSelection =
  | {
      variant?: "gradient" | "flat"
      name: EmptyStateClassicIllustration
    }
  | {
      variant: "color"
      name: EmptyStateColorIllustration
    }

export type EmptyStateIllustrationProps = Omit<
  React.ComponentProps<"img">,
  "src"
> &
  EmptyStateIllustrationSelection & {
    /** Public directory that contains the bundled illustration variants. */
    assetBasePath?: string
  }

/**
 * Staggered entrance for direct children, driven by tw-animate-css so the
 * component stays server-renderable. Media and icons also settle from a
 * slightly smaller scale; every child rises 8px and fades in.
 */
const emptyStateEntrance = [
  "[&>*]:motion-safe:animate-in [&>*]:motion-safe:fade-in [&>*]:motion-safe:slide-in-from-bottom-2 [&>*]:fill-mode-both",
  "[&>*]:[--tw-animation-duration:320ms] [&>*]:[--tw-ease:cubic-bezier(0.22,1,0.36,1)]",
  "[&>[data-slot=empty-state-media]]:motion-safe:zoom-in-95 [&>[data-slot=empty-state-icon]]:motion-safe:zoom-in-90",
  "[&>*:nth-child(2)]:[--tw-animation-delay:60ms] [&>*:nth-child(3)]:[--tw-animation-delay:110ms] [&>*:nth-child(4)]:[--tw-animation-delay:160ms] [&>*:nth-child(5)]:[--tw-animation-delay:210ms] [&>*:nth-child(n+6)]:[--tw-animation-delay:260ms]",
]

export interface EmptyStateProps extends React.ComponentProps<"div"> {
  /** Vertical density preset. @default "default" */
  size?: "sm" | "default" | "lg"
  /** Staggers the media, title, description and actions in when the state mounts. @default true */
  animated?: boolean
}

/** A focused zero-data state with optional illustration and actions. */
function EmptyState({
  className,
  size = "default",
  animated = true,
  ...props
}: EmptyStateProps) {
  return (
    <div
      data-slot="empty-state"
      data-size={size}
      data-animated={animated || undefined}
      className={cn(
        emptyStateVariants({ size }),
        animated && emptyStateEntrance,
        className
      )}
      {...props}
    />
  )
}

function EmptyStateIcon({ className, ...props }: React.ComponentProps<"div">) {
  return (
    <div
      data-slot="empty-state-icon"
      className={cn(
        "bg-muted/50 text-muted-foreground shadow-xs mb-5 flex size-11 items-center justify-center rounded-full border [&_svg]:size-5",
        className
      )}
      {...props}
    />
  )
}

function EmptyStateMedia({ className, ...props }: React.ComponentProps<"div">) {
  return (
    <div
      data-slot="empty-state-media"
      className={cn("mb-6 flex items-center justify-center", className)}
      {...props}
    />
  )
}

function EmptyStateIllustration({
  name,
  variant = "gradient",
  assetBasePath = "/wui/empty-state",
  alt = "",
  className,
  ...props
}: EmptyStateIllustrationProps) {
  return (
    <img
      data-slot="empty-state-illustration"
      src={`${assetBasePath}/${variant}/${name}.svg`}
      alt={alt}
      className={cn("h-auto w-36 max-w-full object-contain", className)}
      loading="lazy"
      decoding="async"
      {...props}
    />
  )
}

function EmptyStateTitle({ className, ...props }: React.ComponentProps<"h3">) {
  return (
    <h3
      data-slot="empty-state-title"
      className={cn("text-base font-semibold tracking-tight", className)}
      {...props}
    />
  )
}

function EmptyStateDescription({
  className,
  ...props
}: React.ComponentProps<"p">) {
  return (
    <p
      data-slot="empty-state-description"
      className={cn(
        "text-muted-foreground mt-1.5 max-w-sm text-sm leading-6",
        className
      )}
      {...props}
    />
  )
}

function EmptyStateActions({
  className,
  ...props
}: React.ComponentProps<"div">) {
  return (
    <div
      data-slot="empty-state-actions"
      className={cn(
        "mt-5 flex flex-wrap items-center justify-center gap-2",
        className
      )}
      {...props}
    />
  )
}

function EmptyStateHint({ className, ...props }: React.ComponentProps<"div">) {
  return (
    <div
      data-slot="empty-state-hint"
      className={cn(
        "text-muted-foreground mt-6 border-t pt-4 text-xs",
        className
      )}
      {...props}
    />
  )
}

export {
  EmptyState,
  EmptyStateActions,
  EmptyStateDescription,
  EmptyStateHint,
  EmptyStateIcon,
  EmptyStateIllustration,
  EmptyStateMedia,
  EmptyStateTitle,
  emptyStateClassicIllustrations,
  emptyStateColorIllustrations,
  emptyStateIllustrations,
  emptyStateVariants,
}

属性 Props

EmptyState

外层容器组件,用于统一设定纵向留白与居中排版:

属性类型默认值说明
size"sm" | "default" | "lg""default"空状态的垂直内边距与最小高度密度。sm 适合卡片或下拉菜单,lg 适合整页或主工作区。
animatedbooleantrue挂载时让插画/图标、标题、描述与操作区依次淡入上浮(约 60ms 间隔)。插画与图标额外从 95% 缩放落定。基于 tw-animate-css,开启“减少动态效果”时自动关闭。
classNamestring—应用于外层容器的额外 CSS 类名。

EmptyStateIllustration

插画组件,用于加载预置的高清矢量插画:

属性类型默认值说明
nameEmptyStateClassicIllustration | EmptyStateColorIllustration—插画的语义名称,内置数十种高频业务插画。
variant"gradient" | "flat" | "color""gradient"插画的视觉艺术风格:gradient 为质感渐变风,flat 为简洁扁平线框风,color 为丰富彩色风。
assetBasePathstring"/wui/empty-state"存放预置插画 SVG 文件的静态资源公共目录根路径。
altstring""图片的无障碍替代文本。装饰性插画建议保持为空以避免读屏器冗余朗读。
classNamestring—应用于插画 <img> 元素的额外 CSS 类名。

结构子组件

  • EmptyStateIcon:小型图标容器(带有圆角浅色背景与微边框),用于轻量或紧凑空状态。
  • EmptyStateMedia:大型媒体/插画包裹容器,负责为插画提供居中对齐与底部间距。
  • EmptyStateTitle:语义化标题(<h3>),加粗显示当前状态核心结论。
  • EmptyStateDescription:辅助描述文本(<p>),控制最大行宽与舒适行高。
  • EmptyStateActions:操作区容器,自动处理多按钮换行间距。
  • EmptyStateHint:底部弱化提示区,带顶部细分割线,适合放置快捷键提示或高级操作建议。

事件 Events

空状态容器本身不直接发射业务事件,所有交互行为(如点击新建、重置筛选、重试请求)由其内部 EmptyStateActions 中挂载的 Button 或链接处理:

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

使用场景与设计规范

空状态不仅是“没有数据”时的占位,更是引导用户开展下一步行为的黄金转化触点。

  • 空状态分类与对应策略:
    • 初次使用(First-time Onboarding):用户首次进入页面尚未创建任何内容(如“还没有项目”)。应提供明确的主引导按钮(如“新建第一个项目”),甚至提供模板导入。
    • 搜索/筛选无结果(Zero Search Results):用户的主动过滤条件未匹配到数据。切忌直接提供“新建”按钮,而应解释“未找到相关结果”,并提供“清空筛选”或“重置搜索”按钮。
    • 内容被清空/已完成(All Done):如所有待办任务已处理完毕、回收站已清空。应使用轻松积极的文案(如“全部处理完毕,去休息一下吧”)。
    • 网络或权限异常:应提供“刷新重试”或“申请权限”操作。
  • 与相关状态组件的区别:
    • 加载中:使用 Skeleton 或 Spin,不要在请求刚发起时提前展示空状态。
    • 操作执行结果反馈:使用 Alert 或 Message/Toast。
  • 操作层级清晰:EmptyStateActions 中最多放置一个主要操作(Primary Button)和一个次要操作(Outline/Ghost Button),避免用户产生决策迟疑。

场景示例

不同场景的空状态

切换首次使用、全部完成与网络异常三种场景。每次挂载 EmptyState 时,子元素会按顺序依次入场;通过 key 让场景切换重新触发入场动效:

Loading…

预置插画库

组件内置 gradient、flat 与 color 三大风格插画系列,涵盖数十种常见业务场景:

Loading…

搜索无结果(重置筛选)

在搜索框中输入关键词过滤文档列表,无匹配项时展示搜索类插画与“清空搜索”操作,清空后列表恢复:

Loading…

紧凑卡片/下拉面板空状态

在通知浮层、侧边栏小部件等小面积容器中,使用 size="sm" 与 EmptyStateIcon 代替大插画:

Loading…

无障碍与交互 Accessibility

  • 语义化标题与描述:使用标准 h3 标题与 p 描述标签,辅助技术用户通过屏幕阅读器标题大纲快速浏览页面状态。
  • 装饰性图像降级:EmptyStateIllustration 默认将 alt 设为空字符串(alt=""),防止读屏器重复朗读冗余的文件名;关键信息已由文本明确表达。
  • 键盘可访问的操作入口:所有操作按钮支持 Tab 聚焦并可通过 Enter / Space 激活。
  • 动效降级:入场动效仅在 prefers-reduced-motion: no-preference 下生效;也可以通过 animated={false} 关闭,例如在高频切换的列表筛选中。
  • 动态状态通报:若空状态是在用户异步搜索后动态出现的,父容器可配合 aria-live="polite",让读屏器能主动通报“未找到匹配结果”。