组件
气泡 Bubble
用于在即时通讯、AI 对话流、评论列表与活动时间线中组合展示头像、昵称、正文内容、时间戳与快捷操作的通用消息气泡。
基础用法
双向聊天对话气泡。通过 side="start" | "end" 区分发送者与接收者:
Loading…
安装与引入
通过 CLI 自动添加组件,或手动复制源码至项目中:
pnpm dlx @wui-design/cli@latest add @wui/bubble安装基础依赖与图标库
pnpm add motion class-variance-authority lucide-react clsx tailwind-merge复制组件源码到
components/ui/bubble.tsx"use client"
import * as React from "react"
import { motion, useReducedMotion, type HTMLMotionProps } from "motion/react"
import { cva } from "class-variance-authority"
import { cn } from "@/lib/utils"
type BubbleSide = "start" | "end"
const BubbleContext = React.createContext<{ side: BubbleSide } | null>(null)
const ease = [0.22, 1, 0.36, 1] as const
const bubbleVariants = cva("group/bubble flex w-full items-start gap-2.5", {
variants: {
side: {
start: "justify-start",
end: "justify-end",
},
},
defaultVariants: {
side: "start",
},
})
const bubbleContentVariants = cva(
"min-w-0 rounded-xl px-3.5 py-2.5 text-sm leading-6 break-words",
{
variants: {
variant: {
default: "bg-muted text-foreground",
primary: "bg-primary text-primary-foreground",
outline: "border bg-background text-foreground",
ghost: "bg-transparent px-0 py-0 text-foreground",
},
side: {
start: "rounded-tl-sm",
end: "rounded-tr-sm",
},
},
defaultVariants: {
variant: "default",
side: "start",
},
}
)
function useBubble() {
const context = React.useContext(BubbleContext)
if (!context) throw new Error("Bubble parts must be used inside <Bubble />")
return context
}
export interface BubbleProps extends React.ComponentProps<"article"> {
/** Horizontal message alignment. @default "start" */
side?: BubbleSide
/** Play the enter motion from the sender side when the message mounts. @default true */
animated?: boolean
}
/** A composable message bubble for chat, comments, and activity threads. */
function Bubble({
className,
side = "start",
animated = true,
style,
...props
}: BubbleProps) {
const reduceMotion = useReducedMotion()
const offset = side === "end" ? 12 : -12
return (
<BubbleContext.Provider value={{ side }}>
<motion.article
data-slot="bubble"
data-side={side}
className={cn(bubbleVariants({ side }), className)}
initial={
animated && !reduceMotion
? { opacity: 0, x: offset, y: 6, scale: 0.98 }
: false
}
animate={{ opacity: 1, x: 0, y: 0, scale: 1 }}
transition={{ duration: 0.28, ease }}
style={{
transformOrigin: side === "end" ? "100% 0%" : "0% 0%",
...style,
}}
{...(props as HTMLMotionProps<"article">)}
/>
</BubbleContext.Provider>
)
}
function BubbleAvatar({ className, ...props }: React.ComponentProps<"div">) {
const { side } = useBubble()
return (
<div
data-slot="bubble-avatar"
data-side={side}
className={cn(
// Align with the message surface: offset by the header row only when one exists.
"shrink-0 group-has-[[data-slot=bubble-header]]/bubble:mt-5",
side === "end" && "order-2",
className
)}
{...props}
/>
)
}
function BubbleBody({ className, ...props }: React.ComponentProps<"div">) {
const { side } = useBubble()
return (
<div
data-slot="bubble-body"
data-side={side}
className={cn(
"flex min-w-0 max-w-[82%] flex-col gap-1",
side === "end" && "items-end",
className
)}
{...props}
/>
)
}
function BubbleHeader({
className,
...props
}: React.ComponentProps<"header">) {
const { side } = useBubble()
return (
<header
data-slot="bubble-header"
data-side={side}
className={cn(
"flex min-h-4 items-center gap-2 px-1 text-xs text-muted-foreground",
side === "end" && "flex-row-reverse",
className
)}
{...props}
/>
)
}
export interface BubbleContentProps extends React.ComponentProps<"div"> {
/** Surface treatment for the message. @default "default" */
variant?: "default" | "primary" | "outline" | "ghost"
}
function BubbleContent({
className,
variant = "default",
...props
}: BubbleContentProps) {
const { side } = useBubble()
return (
<div
data-slot="bubble-content"
data-side={side}
data-variant={variant}
className={cn(bubbleContentVariants({ side, variant }), className)}
{...props}
/>
)
}
export interface BubbleTypingProps extends React.ComponentProps<"span"> {
/** Accessible status announced while the sender is composing. @default "正在输入" */
label?: string
}
/** Three staggered dots signalling that the sender is composing a reply. */
function BubbleTyping({
className,
label = "正在输入",
...props
}: BubbleTypingProps) {
const reduceMotion = useReducedMotion()
return (
<span
role="status"
aria-label={label}
data-slot="bubble-typing"
className={cn("flex h-6 items-center gap-1", className)}
{...props}
>
{[0, 1, 2].map((index) => (
<motion.span
key={index}
aria-hidden
className="size-1.5 rounded-full bg-current opacity-40"
animate={
reduceMotion
? undefined
: { y: [0, -3, 0], opacity: [0.35, 0.9, 0.35] }
}
transition={{
duration: 1,
ease: "easeInOut",
repeat: Infinity,
delay: index * 0.16,
}}
/>
))}
</span>
)
}
function BubbleFooter({
className,
...props
}: React.ComponentProps<"footer">) {
const { side } = useBubble()
return (
<footer
data-slot="bubble-footer"
data-side={side}
className={cn(
"flex items-center gap-2 px-1 text-xs text-muted-foreground",
side === "end" && "flex-row-reverse",
className
)}
{...props}
/>
)
}
function BubbleActions({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="bubble-actions"
className={cn("flex items-center gap-0.5", className)}
{...props}
/>
)
}
export {
Bubble,
BubbleActions,
BubbleAvatar,
BubbleBody,
BubbleContent,
BubbleFooter,
BubbleHeader,
BubbleTyping,
bubbleContentVariants,
bubbleVariants,
}
属性 Props
Bubble (根组件)
继承原生 <article> 元素的全部 HTML 属性:
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| side | "start" | "end" | "start" | 消息气泡的水平对齐方向。'start' 靠左对齐(通常代表对方/AI 助手);'end' 靠右对齐(代表当前登录用户)。 |
| animated | boolean | true | 挂载时是否播放入场动效:从发送方一侧轻微位移、淡入并以头像所在角为原点缩放。系统开启减弱动态效果时自动跳过。 |
| className | string | — | 应用于外层气泡容器的额外 CSS 类名。根节点带有 `group/bubble`,子元素可用 `group-hover/bubble:` 做悬停显隐。 |
BubbleContent
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| variant | "default" | "primary" | "outline" | "ghost" | "default" | 气泡主体的视觉质感风格。'primary' 适合当前用户消息;'outline' 适合 AI 回答卡片;'ghost' 适合超长 markdown 流式正文。 |
| className | string | — | 应用于消息气泡内容盒子的额外 CSS 类名。 |
BubbleTyping
放在 BubbleContent 中表示对方正在输入,三个圆点错峰起伏;继承原生 <span> 属性:
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| label | string | "正在输入" | 以 `role="status"` 播报给屏幕阅读器的状态文案。 |
BubbleAvatar / BubbleBody / BubbleHeader / BubbleFooter / BubbleActions
用于灵活拼装消息各个功能区域的子组件:
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| children | React.ReactNode | — | 各子容器内部的内容展示(如 `<Avatar>`、时间标签 `<time>`、操作按钮组)。 |
| className | string | — | 应用于子容器的额外 CSS 类名。 |
事件 Events
继承原生 <article>、<div> 与各子容器的标准 HTML 事件:
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| onClick | (event: React.MouseEvent) => void | — | 点击气泡容器或内部操作按钮时触发。 |
使用场景与设计规范
Bubble 是现代 IM 协作与 AI 智能体产品最核心的基础呈现单元:
- 左右对齐规范:
side="start":接收到的消息、AI 模型回答、客服接待语。side="end":当前用户自己发送的消息。
- 长文本排版优化:对于 AI 回答的长篇 Markdown、代码块或富表格,推荐使用
variant="outline"或variant="ghost",避免深底色带来的阅读疲劳。 - 操作栏微交互:
BubbleActions可配合opacity-0 group-hover/bubble:opacity-100 focus-within:opacity-100悬停显隐(见基础用法),避免常驻过多小图标干扰文本浏览,同时保证键盘聚焦时可见。 - 入场动效:新消息默认从发送方一侧淡入;一次性加载大量历史消息时可传
animated={false},只让新到达的消息产生动效。
场景示例
实时对话与输入状态
发送消息后气泡从发送方一侧滑入,对方回复前以 BubbleTyping 展示输入中状态:
Loading…
AI 回答与 Markdown
variant="ghost" 承载 Markdown 渲染的长回答,底部放置复制、重新生成与反馈操作:
Loading…
四种表面质感 (Variants)
提供 default(灰底)、primary(品牌主色)、outline(带边框)与 ghost(纯净无底)四种视觉样式:
Loading…
无障碍与交互 Accessibility
- 独立语义容器:根组件自动渲染为
<article data-slot="bubble">,向辅助技术清晰传达每一条消息作为独立语义片段的存在。 - 时间可读性:时间戳建议采用标准的
<time dateTime="...">标签,确保机器与屏幕阅读器能够正确解析 ISO 时间格式。 - 动作按钮无障碍命名:
BubbleActions中的图标按钮(如复制、重试)必须提供aria-label属性,方便键盘焦点播报。