wui
组件

气泡 Bubble

用于在即时通讯、AI 对话流、评论列表与活动时间线中组合展示头像、昵称、正文内容、时间戳与快捷操作的通用消息气泡。

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

基础用法

双向聊天对话气泡。通过 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
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' 靠右对齐(代表当前登录用户)。
animatedbooleantrue挂载时是否播放入场动效:从发送方一侧轻微位移、淡入并以头像所在角为原点缩放。系统开启减弱动态效果时自动跳过。
classNamestring—应用于外层气泡容器的额外 CSS 类名。根节点带有 `group/bubble`,子元素可用 `group-hover/bubble:` 做悬停显隐。

BubbleContent

属性类型默认值说明
variant"default" | "primary" | "outline" | "ghost""default"气泡主体的视觉质感风格。'primary' 适合当前用户消息;'outline' 适合 AI 回答卡片;'ghost' 适合超长 markdown 流式正文。
classNamestring—应用于消息气泡内容盒子的额外 CSS 类名。

BubbleTyping

放在 BubbleContent 中表示对方正在输入,三个圆点错峰起伏;继承原生 <span> 属性:

属性类型默认值说明
labelstring"正在输入"以 `role="status"` 播报给屏幕阅读器的状态文案。

BubbleAvatar / BubbleBody / BubbleHeader / BubbleFooter / BubbleActions

用于灵活拼装消息各个功能区域的子组件:

属性类型默认值说明
childrenReact.ReactNode—各子容器内部的内容展示(如 `<Avatar>`、时间标签 `<time>`、操作按钮组)。
classNamestring—应用于子容器的额外 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 属性,方便键盘焦点播报。