组件
空状态 Empty State
在页面或模块无数据、无搜索结果或未配置时,清晰解释现状并引导用户开展下一步动作。
基础用法
最基础的空状态用法。组合插画或图标、标题、解释说明文本以及主/次操作按钮:
Loading…
安装与引入
通过 CLI 自动添加组件,或手动复制源码至项目中:
pnpm dlx @wui-design/cli@latest add @wui/empty-state安装基础依赖与工具函数
pnpm add class-variance-authority clsx tailwind-merge复制组件源码到
components/ui/empty-state.tsximport * 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 适合整页或主工作区。 |
| animated | boolean | true | 挂载时让插画/图标、标题、描述与操作区依次淡入上浮(约 60ms 间隔)。插画与图标额外从 95% 缩放落定。基于 tw-animate-css,开启“减少动态效果”时自动关闭。 |
| className | string | — | 应用于外层容器的额外 CSS 类名。 |
EmptyStateIllustration
插画组件,用于加载预置的高清矢量插画:
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| name | EmptyStateClassicIllustration | EmptyStateColorIllustration | — | 插画的语义名称,内置数十种高频业务插画。 |
| variant | "gradient" | "flat" | "color" | "gradient" | 插画的视觉艺术风格:gradient 为质感渐变风,flat 为简洁扁平线框风,color 为丰富彩色风。 |
| assetBasePath | string | "/wui/empty-state" | 存放预置插画 SVG 文件的静态资源公共目录根路径。 |
| alt | string | "" | 图片的无障碍替代文本。装饰性插画建议保持为空以避免读屏器冗余朗读。 |
| className | string | — | 应用于插画 <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",让读屏器能主动通报“未找到匹配结果”。