wui
组件

AI 生成物 AI Artifact

类似 Claude Artifacts 与 v0 的交互式产物工作区容器,支持代码、实时组件预览与运行控制台的多模态无缝切换。

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

基础用法

包含组件名称、文件类型徽章、代码与实时交互预览双模切换,以及一键复制和全屏沉浸模式的生成物容器:

Loading…

安装与引入

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

pnpm dlx @wui-design/cli@latest add @wui/ai-artifact
安装基础依赖与动效库
pnpm add motion class-variance-authority lucide-react clsx tailwind-merge
复制组件源码到 components/ui/ai-artifact.tsx
components/ui/ai-artifact.tsx
"use client"

import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { AnimatePresence, motion, useReducedMotion } from "motion/react"
import {
  CodeIcon,
  CopyIcon,
  CheckIcon,
  EyeIcon,
  Maximize2Icon,
  Minimize2Icon,
  SparklesIcon,
  TerminalIcon,
} from "lucide-react"

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

type AiArtifactTabType = "code" | "preview" | "diff" | "console" | (string & {})

interface AiArtifactContextValue {
  activeTab: string
  setActiveTab: (tab: string) => void
  isFullscreen: boolean
  setIsFullscreen: (fullscreen: boolean) => void
  isStreaming?: boolean
  copied: boolean
  setCopied: React.Dispatch<React.SetStateAction<boolean>>
  /** Stable per-instance id used for tab/panel relationships and the tab indicator. */
  baseId: string
}

const AiArtifactContext = React.createContext<AiArtifactContextValue | null>(null)

const swapSpring = {
  type: "spring",
  stiffness: 520,
  damping: 32,
  mass: 0.6,
} as const

function useAiArtifact() {
  const context = React.useContext(AiArtifactContext)
  if (!context) {
    throw new Error("AI artifact components must be used within <AiArtifact />")
  }
  return context
}

function toDomId(value: string) {
  return value.replace(/[^a-zA-Z0-9_-]/g, "-")
}

const aiArtifactVariants = cva(
  "relative flex flex-col overflow-hidden rounded-lg border bg-background",
  {
    variants: {
      variant: {
        default: "border-border shadow-xs",
        bordered: "border-border shadow-none",
        ghost: "border-transparent bg-muted/30 shadow-none",
      },
    },
    defaultVariants: {
      variant: "default",
    },
  }
)

export interface AiArtifactProps
  extends React.ComponentProps<"div">,
    VariantProps<typeof aiArtifactVariants> {
  /** 当前激活的标签页标识(受控模式)。 */
  activeTab?: string
  /** 默认激活的标签页标识(非受控模式)。 @default "preview" */
  defaultTab?: string
  /** 标签页切换时的回调函数。 */
  onTabChange?: (tab: string) => void
  /** 是否处于全屏沉浸模式(受控)。 */
  isFullscreen?: boolean
  /** 默认是否全屏(非受控)。 @default false */
  defaultFullscreen?: boolean
  /** 全屏状态切换时的回调函数。 */
  onFullscreenChange?: (fullscreen: boolean) => void
  /** 是否处于正在流式生成或运行中。 @default false */
  isStreaming?: boolean
  /** 外观样式变体。 @default "default" */
  variant?: "default" | "bordered" | "ghost"
}

/** A container for AI-generated code, interactive widgets, HTML and design previews. */
function AiArtifact({
  className,
  variant,
  activeTab: controlledTab,
  defaultTab = "preview",
  onTabChange,
  isFullscreen: controlledFullscreen,
  defaultFullscreen = false,
  onFullscreenChange,
  isStreaming = false,
  children,
  ...props
}: AiArtifactProps) {
  const [internalTab, setInternalTab] = React.useState(defaultTab)
  const [internalFullscreen, setInternalFullscreen] = React.useState(defaultFullscreen)
  const [copied, setCopied] = React.useState(false)
  const baseId = React.useId()

  const activeTab = controlledTab ?? internalTab
  const isFullscreen = controlledFullscreen ?? internalFullscreen

  const handleTabChange = React.useCallback(
    (tab: string) => {
      if (controlledTab === undefined) {
        setInternalTab(tab)
      }
      onTabChange?.(tab)
    },
    [controlledTab, onTabChange]
  )

  const handleFullscreenChange = React.useCallback(
    (fullscreen: boolean) => {
      if (controlledFullscreen === undefined) {
        setInternalFullscreen(fullscreen)
      }
      onFullscreenChange?.(fullscreen)
    },
    [controlledFullscreen, onFullscreenChange]
  )

  React.useEffect(() => {
    if (!isFullscreen) return
    const onKeyDown = (event: KeyboardEvent) => {
      if (event.key === "Escape") handleFullscreenChange(false)
    }
    window.addEventListener("keydown", onKeyDown)
    return () => window.removeEventListener("keydown", onKeyDown)
  }, [handleFullscreenChange, isFullscreen])

  return (
    <AiArtifactContext.Provider
      value={{
        activeTab,
        setActiveTab: handleTabChange,
        isFullscreen,
        setIsFullscreen: handleFullscreenChange,
        isStreaming,
        copied,
        setCopied,
        baseId,
      }}
    >
      {isFullscreen ? (
        <div
          aria-hidden
          data-slot="ai-artifact-backdrop"
          className="fixed inset-0 z-50 bg-overlay duration-200 animate-in fade-in-0"
          onClick={() => handleFullscreenChange(false)}
        />
      ) : null}
      <div
        data-slot="ai-artifact"
        data-streaming={isStreaming ? "true" : "false"}
        data-fullscreen={isFullscreen ? "true" : "false"}
        role={isFullscreen ? "dialog" : undefined}
        aria-modal={isFullscreen || undefined}
        className={cn(
          aiArtifactVariants({ variant }),
          isFullscreen &&
            "fixed inset-4 z-50 bg-background shadow-lg duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] animate-in fade-in-0 zoom-in-[0.98] md:inset-8",
          className
        )}
        {...props}
      >
        {children}
      </div>
    </AiArtifactContext.Provider>
  )
}

export interface AiArtifactHeaderProps extends React.ComponentProps<"header"> {
  /** Optional badge or tag displayed near title. */
  badge?: React.ReactNode
}

function AiArtifactHeader({
  className,
  badge,
  children,
  ...props
}: AiArtifactHeaderProps) {
  const { isStreaming } = useAiArtifact()
  const reduceMotion = useReducedMotion()

  return (
    <header
      data-slot="ai-artifact-header"
      className={cn(
        "relative flex flex-wrap items-center justify-between gap-2 border-b bg-muted/30 px-3.5 py-2.5 sm:px-4",
        className
      )}
      {...props}
    >
      <div className="flex min-w-0 flex-1 items-center gap-2.5">
        {children}
        {badge}
      </div>
      <AnimatePresence>
        {isStreaming && !reduceMotion ? (
          <motion.span
            key="streaming"
            aria-hidden
            data-slot="ai-artifact-progress"
            className="pointer-events-none absolute inset-x-0 -bottom-px h-px overflow-hidden"
            initial={{ opacity: 0 }}
            animate={{ opacity: 1 }}
            exit={{ opacity: 0 }}
            transition={{ duration: 0.2 }}
          >
            <motion.span
              className="block h-full w-1/4 bg-info"
              initial={{ x: "-100%" }}
              animate={{ x: "400%" }}
              transition={{
                duration: 1.5,
                ease: [0.45, 0, 0.55, 1],
                repeat: Infinity,
              }}
            />
          </motion.span>
        ) : null}
      </AnimatePresence>
    </header>
  )
}

export interface AiArtifactTitleProps extends React.ComponentProps<"div"> {
  /** Icon displayed to the left of the artifact title. */
  icon?: React.ReactNode
}

function AiArtifactTitle({
  className,
  icon,
  children,
  ...props
}: AiArtifactTitleProps) {
  const { isStreaming } = useAiArtifact()
  return (
    <div
      data-slot="ai-artifact-title"
      className={cn(
        "flex min-w-0 items-center gap-2 text-sm font-medium text-foreground",
        className
      )}
      {...props}
    >
      <span className="text-muted-foreground flex size-4 shrink-0 items-center justify-center">
        {icon ?? (
          <SparklesIcon
            className={cn(
              "size-3.5 transition-colors",
              isStreaming && "text-info motion-safe:animate-pulse"
            )}
          />
        )}
      </span>
      <span className="truncate">{children}</span>
    </div>
  )
}

function AiArtifactDescription({
  className,
  ...props
}: React.ComponentProps<"p">) {
  return (
    <p
      data-slot="ai-artifact-description"
      className={cn("truncate text-xs text-muted-foreground", className)}
      {...props}
    />
  )
}

function AiArtifactActions({
  className,
  ...props
}: React.ComponentProps<"div">) {
  return (
    <div
      data-slot="ai-artifact-actions"
      className={cn("flex items-center gap-1 text-muted-foreground", className)}
      {...props}
    />
  )
}

export interface AiArtifactActionProps extends React.ComponentProps<"button"> {
  /** Visual tooltip or aria-label for accessibility. */
  label?: string
}

function AiArtifactAction({
  className,
  label,
  children,
  ...props
}: AiArtifactActionProps) {
  return (
    <button
      type="button"
      data-slot="ai-artifact-action"
      aria-label={label}
      title={label}
      className={cn(
        "relative inline-flex size-7 items-center justify-center rounded-md text-muted-foreground transition-[color,background-color,scale] duration-150 hover:bg-muted hover:text-foreground focus-visible:ring-ring/35 focus-visible:outline-none focus-visible:ring-[3px] active:scale-90 disabled:pointer-events-none disabled:opacity-50 motion-reduce:active:scale-100",
        className
      )}
      {...props}
    >
      {children}
    </button>
  )
}

export interface AiArtifactCopyProps extends Omit<AiArtifactActionProps, "children"> {
  /** Text or code content to copy to clipboard. */
  content: string
  /** Callback fired upon successful copy. */
  onCopy?: () => void
}

function AiArtifactCopy({
  content,
  onCopy,
  label = "复制代码",
  className,
  ...props
}: AiArtifactCopyProps) {
  const { copied, setCopied } = useAiArtifact()
  const reduceMotion = useReducedMotion()
  const timerRef = React.useRef<number | undefined>(undefined)

  React.useEffect(() => () => window.clearTimeout(timerRef.current), [])

  const handleCopy = React.useCallback(async () => {
    try {
      await navigator.clipboard.writeText(content)
    } catch {
      return
    }
    setCopied(true)
    onCopy?.()
    window.clearTimeout(timerRef.current)
    timerRef.current = window.setTimeout(() => setCopied(false), 2000)
  }, [content, onCopy, setCopied])

  return (
    <AiArtifactAction
      label={copied ? "已复制" : label}
      onClick={handleCopy}
      className={className}
      {...props}
    >
      <AnimatePresence initial={false} mode="popLayout">
        <motion.span
          key={copied ? "check" : "copy"}
          initial={
            reduceMotion ? false : { scale: 0.5, opacity: 0, filter: "blur(2px)" }
          }
          animate={{ scale: 1, opacity: 1, filter: "blur(0px)" }}
          exit={
            reduceMotion
              ? undefined
              : { scale: 0.5, opacity: 0, filter: "blur(2px)" }
          }
          transition={reduceMotion ? { duration: 0 } : swapSpring}
          className={cn(
            "flex items-center justify-center",
            copied && "text-success"
          )}
        >
          {copied ? (
            <CheckIcon className="size-3.5" />
          ) : (
            <CopyIcon className="size-3.5" />
          )}
        </motion.span>
      </AnimatePresence>
    </AiArtifactAction>
  )
}

function AiArtifactFullscreenToggle({
  className,
  ...props
}: Omit<AiArtifactActionProps, "children">) {
  const { isFullscreen, setIsFullscreen } = useAiArtifact()
  const reduceMotion = useReducedMotion()

  return (
    <AiArtifactAction
      label={isFullscreen ? "退出全屏" : "全屏查看"}
      aria-pressed={isFullscreen}
      onClick={() => setIsFullscreen(!isFullscreen)}
      className={className}
      {...props}
    >
      <AnimatePresence initial={false} mode="popLayout">
        <motion.span
          key={isFullscreen ? "min" : "max"}
          className="flex items-center justify-center"
          initial={reduceMotion ? false : { scale: 0.5, opacity: 0 }}
          animate={{ scale: 1, opacity: 1 }}
          exit={reduceMotion ? undefined : { scale: 0.5, opacity: 0 }}
          transition={reduceMotion ? { duration: 0 } : swapSpring}
        >
          {isFullscreen ? (
            <Minimize2Icon className="size-3.5" />
          ) : (
            <Maximize2Icon className="size-3.5" />
          )}
        </motion.span>
      </AnimatePresence>
    </AiArtifactAction>
  )
}

function AiArtifactTabList({
  className,
  onKeyDown,
  ...props
}: React.ComponentProps<"div">) {
  return (
    <div
      data-slot="ai-artifact-tab-list"
      role="tablist"
      className={cn(
        "inline-flex h-8 items-center rounded-md bg-muted p-0.5 text-muted-foreground",
        className
      )}
      onKeyDown={(event) => {
        onKeyDown?.(event)
        if (event.defaultPrevented) return
        const keys = ["ArrowLeft", "ArrowRight", "Home", "End"]
        if (!keys.includes(event.key)) return

        const tabs = Array.from(
          event.currentTarget.querySelectorAll<HTMLButtonElement>(
            '[role="tab"]:not(:disabled)'
          )
        )
        const index = tabs.indexOf(document.activeElement as HTMLButtonElement)
        if (index < 0) return

        event.preventDefault()
        const next =
          event.key === "Home"
            ? 0
            : event.key === "End"
              ? tabs.length - 1
              : (index + (event.key === "ArrowRight" ? 1 : -1) + tabs.length) %
                tabs.length
        tabs[next]?.focus()
        tabs[next]?.click()
      }}
      {...props}
    />
  )
}

export interface AiArtifactTabTriggerProps extends React.ComponentProps<"button"> {
  /** Tab value identifier matching the tab panels. */
  value: AiArtifactTabType
  /** Optional icon rendered next to tab label. */
  icon?: React.ReactNode
}

function AiArtifactTabTrigger({
  value,
  icon,
  className,
  children,
  onClick,
  ...props
}: AiArtifactTabTriggerProps) {
  const { activeTab, setActiveTab, baseId } = useAiArtifact()
  const reduceMotion = useReducedMotion()
  const isSelected = activeTab === value

  const defaultIcon =
    value === "preview" ? (
      <EyeIcon className="size-3.5" />
    ) : value === "code" ? (
      <CodeIcon className="size-3.5" />
    ) : value === "console" ? (
      <TerminalIcon className="size-3.5" />
    ) : null

  return (
    <button
      type="button"
      role="tab"
      id={`${baseId}-tab-${toDomId(value)}`}
      aria-selected={isSelected}
      aria-controls={`${baseId}-panel-${toDomId(value)}`}
      tabIndex={isSelected ? 0 : -1}
      data-slot="ai-artifact-tab-trigger"
      data-state={isSelected ? "active" : "inactive"}
      onClick={(event) => {
        onClick?.(event)
        if (!event.defaultPrevented) setActiveTab(value)
      }}
      className={cn(
        "relative isolate inline-flex h-7 items-center justify-center gap-1.5 rounded-sm px-2.5 text-xs font-medium outline-none transition-colors duration-200 focus-visible:ring-2 focus-visible:ring-ring/35",
        isSelected
          ? "text-foreground"
          : "text-muted-foreground hover:text-foreground",
        className
      )}
      {...props}
    >
      {isSelected && (
        <motion.span
          aria-hidden
          data-slot="ai-artifact-tab-indicator"
          layoutId={`${baseId}-tab-indicator`}
          className="absolute inset-0 z-[-1] rounded-sm bg-background shadow-xs"
          transition={
            reduceMotion
              ? { duration: 0 }
              : { type: "spring", stiffness: 520, damping: 38, mass: 0.7 }
          }
        />
      )}
      <span className="flex items-center gap-1.5">
        {icon ?? defaultIcon}
        {children}
      </span>
    </button>
  )
}

export interface AiArtifactPanelProps extends React.ComponentProps<"div"> {
  /** The tab value this panel corresponds to. */
  value: AiArtifactTabType
}

function AiArtifactPanel({
  value,
  className,
  children,
  ...props
}: AiArtifactPanelProps) {
  const { activeTab, baseId } = useAiArtifact()
  if (activeTab !== value) return null

  return (
    <div
      role="tabpanel"
      id={`${baseId}-panel-${toDomId(value)}`}
      aria-labelledby={`${baseId}-tab-${toDomId(value)}`}
      data-slot="ai-artifact-panel"
      data-tab={value}
      className={cn(
        "min-h-0 flex-1 overflow-auto p-4 duration-300 ease-[cubic-bezier(0.22,1,0.36,1)] motion-safe:animate-in motion-safe:fade-in-0 motion-safe:blur-in-[2px]",
        className
      )}
      {...props}
    >
      {children}
    </div>
  )
}

function AiArtifactBody({
  className,
  children,
  ...props
}: React.ComponentProps<"div">) {
  return (
    <div
      data-slot="ai-artifact-body"
      className={cn("relative flex min-h-64 flex-1 flex-col overflow-hidden", className)}
      {...props}
    >
      {children}
    </div>
  )
}

export interface AiArtifactCodeProps extends React.ComponentProps<"pre"> {
  /** Code content string. */
  code?: string
  /** Programming language identifier. */
  language?: string
}

function AiArtifactCode({
  className,
  code,
  language,
  children,
  ...props
}: AiArtifactCodeProps) {
  return (
    <div
      data-slot="ai-artifact-code"
      className="relative flex h-full w-full flex-col font-mono text-xs"
    >
      {language && (
        <span className="text-muted-foreground/70 absolute right-3 top-3 select-none text-[11px] uppercase tracking-wider">
          {language}
        </span>
      )}
      <pre
        className={cn(
          "h-full w-full overflow-auto bg-muted/40 p-4 leading-relaxed text-foreground",
          className
        )}
        {...props}
      >
        <code>{code ?? children}</code>
      </pre>
    </div>
  )
}

function AiArtifactPreview({
  className,
  ...props
}: React.ComponentProps<"div">) {
  return (
    <div
      data-slot="ai-artifact-preview"
      className={cn(
        "flex h-full min-h-64 w-full flex-col items-center justify-center overflow-auto bg-muted/20 p-6",
        className
      )}
      {...props}
    />
  )
}

export {
  AiArtifact,
  AiArtifactAction,
  AiArtifactActions,
  AiArtifactBody,
  AiArtifactCode,
  AiArtifactCopy,
  AiArtifactDescription,
  AiArtifactFullscreenToggle,
  AiArtifactHeader,
  AiArtifactPanel,
  AiArtifactPreview,
  AiArtifactTabList,
  AiArtifactTabTrigger,
  AiArtifactTitle,
  aiArtifactVariants,
  useAiArtifact,
}

属性 Props

AiArtifact (根容器)

统筹当前激活的标签页、全屏状态与流式生成状态:

属性类型默认值说明
activeTabstring—受控模式下当前激活的标签页标识(如 `"preview"` 或 `"code"`)。
defaultTabstring"preview"非受控模式下默认激活的标签页标识。
onTabChange(tab: string) => void—标签页发生切换时的回调函数。
isFullscreenboolean—受控模式下的全屏展开状态。
defaultFullscreenbooleanfalse非受控模式下初始是否全屏呈现。
onFullscreenChange(fullscreen: boolean) => void—全屏状态切换时的回调函数。
isStreamingbooleanfalse产物是否正处于大模型流式生成或热重载编译中。
variant"default" | "bordered" | "ghost""default"外层卡片的边框与投影视觉风格。

AiArtifactHeader / AiArtifactTitle

属性类型默认值说明
badgeReact.ReactNode—渲染在标题右侧的文件类型或语言徽章(如 `<Badge>React</Badge>`)。
iconReact.ReactNode—标题左侧自定义图标。生成中自动呈现呼吸微动效。

AiArtifactCopy (一键复制按钮)

属性类型默认值说明
contentstring—需要复制到系统剪贴板的纯文本或代码字符串。
onCopy() => void—复制成功后的回调通知。
labelstring"复制代码"无障碍按钮标签与悬停提示文案。

AiArtifactTabTrigger / AiArtifactPanel

属性类型默认值说明
value"preview" | "code" | "console" | "diff" | string—当前标签页的唯一标识符,Trigger 与 Panel 需保持一致。
iconReact.ReactNode—自定义标签页左侧图标。

AiArtifactCode (代码展示面板)

属性类型默认值说明
codestring—源代码字符串内容。
languagestring—编程语言名称标识(如 `tsx`、`python`、`svg`),自动在右上角呈现语言徽标。

事件 Events

属性类型默认值说明
onTabChange(tab: string) => void—用户在 Preview、Code、Console 之间切换时触发。
onFullscreenChange(fullscreen: boolean) => void—点击全屏切换按钮触发,返回最新的全屏布尔值。
onCopy() => void—用户成功复制代码内容时触发。

使用场景与设计规范

AiArtifact 用于将 AI 生成的长篇代码、交互式组件、SVG 图表、Markdown 文档或 HTML 原型提升为独立的工作区容器:

  • 何时使用:
    • 代码生成助手(类似 Claude Artifacts / v0 / Cursor Composer 产物面板);
    • 既需要查看完整源代码、又需要实时渲染交互式 UI 组件的场景;
    • 带有运行日志、编译控制台或多文件切换的 AI 智能工作流。
  • 何时不应使用:
    • 对话中仅两三行的内联代码片段(建议直接使用标准 Markdown 代码块);
    • 静态只读文档展示。
  • 设计最佳实践:
    • 平滑切换动效:标签页切换基于 Motion 弹性滑块 (layoutId),视觉连贯高级;
    • 全屏沉浸支持:在复杂图表或仪表盘生成场景下,提供全屏查看按钮;
    • 沙箱隔离安全性:若在 Preview 中运行动态生成的外部 HTML/JS,请置于安全的 iframe 沙箱中执行。

场景示例

多标签多模态工作区 (Preview + Code + Console)

包含预览、源码与构建日志三个标签。点击「重新生成」时自动切到代码标签,头部出现生成进度线,代码逐段写入:

Loading…

无障碍与交互 Accessibility

  • Tablist 规范 (WAI-ARIA Tabs):
    • 标签栏挂载 role="tablist",各触发按钮挂载 role="tab"、aria-selected 与 aria-controls,并采用漫游焦点(仅当前标签可 Tab 聚焦);
    • 内容面板挂载 role="tabpanel" 与 aria-labelledby,保证键盘与屏幕阅读器无障碍对应。
  • 全屏模态焦点管理:
    • 进入全屏模式后显示遮罩层,点击遮罩或按 Esc 即可退出全屏;
    • 复制成功后按钮具有明确的动效勾选与 aria-label="已复制" 提示。
  • 键盘导航支持:
    • Tab / Shift + Tab:在标签栏与操作按钮之间轮转焦点;
    • ← / → / Home / End:在标签之间切换;
    • Space / Enter:激活选中的标签页或触发复制。