HextaUI

Artifact

The panel beside an AI chat that shows what the model made. Web pages, SVGs, documents and code stream in live, then switch to a sandboxed preview, with versions you can compare and restore, a resizable split and a bottom sheet on phones.

When a model makes something worth keeping, like a web page, a document or a file, it belongs beside the chat instead of inside it. Artifact Workspace puts your chat and a panel side by side. The panel opens on its own when an artifact starts streaming, slides in while the chat makes room, and can be resized, expanded to take the whole width or dragged closed. On phones it becomes a bottom sheet that only opens when you tap a card, so reading the chat is never interrupted.

While the model writes, the panel shows highlighted code that follows new lines, with a Jump to latest button if you scroll up. Web pages and SVGs switch to their preview when writing finishes, never mid-stream, and the preview keeps showing the last finished version until then. Documents render as Markdown while they stream. Previews run in a sandboxed iframe with no access to your site, swap in only after the new version has loaded so there is no white flash, and report runtime errors with a Fix it action you can send back to the model.

Small changes don’t rewrite the file. An update can send edits, each a find and replace applied in order to the last finished version, like Claude’s artifacts. The code stays on screen and scrolls to each edit, removed lines are struck through and fold away, new text types in with a green tint, and the rest of the file stays still. The card says Editing with the number of changes. An edit whose text isn’t found, or is found more than once, fails that version with a message saying which edit and why, and the last good version stays current. Use edits for local changes and the whole content when most of the file or its structure changes.

Every update is a new version. The header lists them with what changed, Show changes compares a version with the one before it line by line with the same diff view as Diff Review, and older versions can be restored without deleting anything. The added and removed counts beside the toggle and on the card are the rows the diff shows. A new version always comes to the front. Cards in the chat show the version their message made, so clicking an old card opens that version. getArtifactsFromMessages builds all of this from AI SDK tool calls, and the panel, code view and preview also work on their own.

  1. Add the Pro registry to components.json

    components.json
    {
      "registries": {
        "@hextaui-pro": {
          "url": "https://hextaui.com/r/pro/{name}.json",
          "headers": {
            "Authorization": "Bearer ${HEXTAUI_PRO_TOKEN}"
          }
        }
      }
    }
  2. Add your token

    Create a token on your account page and put it in .env.local as HEXTAUI_PRO_TOKEN.

  3. Add the block

    pnpm dlx shadcn@latest add @hextaui-pro/artifact

With the AI SDK

getArtifactsFromMessages turns create_artifact and update_artifact tool calls from useChat into artifacts with versions. Put an ArtifactCard where each call appears, and restore by adding a finished update call with setMessages. update_artifact runs in the browser, so applyEdits can tell the model when an edit didn’t match and it can try again.

"use client"

import * as React from "react"
import { useChat } from "@ai-sdk/react"
import { isToolUIPart, lastAssistantMessageIsCompleteWithToolCalls, type UIMessage } from "ai"

import { ChatAssistantMessage, ChatThread, ChatUserMessage } from "../chat-thread/chat-thread"
import { Markdown } from "../markdown/markdown"
import { PromptInput, PromptInputActions, PromptInputBody, PromptInputSubmit, PromptInputTextarea } from "../prompt-input/prompt-input"
import { ArtifactCard, ArtifactWorkspace } from "@/components/blocks/artifact/artifact"
import {
  applyEdits,
  findVersion,
  getArtifactsFromMessages,
  lastCompleteVersion,
  type ArtifactEdit,
} from "@/components/blocks/artifact/artifacts"

function textOf(message: UIMessage) {
  return message.parts.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n\n")
}

export function Assistant() {
  const latest = React.useRef<UIMessage[]>([])
  const { messages, setMessages, sendMessage, status, stop, addToolOutput } = useChat({
    sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithToolCalls,
    onToolCall({ toolCall }) {
      if (toolCall.dynamic || toolCall.toolName !== "update_artifact") return
      const input = toolCall.input as { id?: string; content?: string; edits?: ArtifactEdit[] }
      const artifact = getArtifactsFromMessages(latest.current).find((item) => item.id === input.id)
      const earlier = artifact?.versions.filter((version) => version.id !== toolCall.toolCallId) ?? []
      const result =
        input.content === undefined
          ? applyEdits(lastCompleteVersion({ versions: earlier })?.content ?? "", input.edits ?? [])
          : null
      if (result?.error) {
        addToolOutput({ tool: "update_artifact", toolCallId: toolCall.toolCallId, state: "output-error", errorText: result.error })
      } else {
        addToolOutput({ tool: "update_artifact", toolCallId: toolCall.toolCallId, output: { ok: true } })
      }
    },
  })
  React.useLayoutEffect(() => {
    latest.current = messages
  })
  const busy = status === "submitted" || status === "streaming"
  const artifacts = React.useMemo(
    () => getArtifactsFromMessages(messages, { streaming: busy }),
    [messages, busy]
  )

  return (
    <ArtifactWorkspace
      artifacts={artifacts}
      onFix={(artifact, _version, error) =>
        sendMessage({ text: `The preview of ${artifact.title} shows an error: ${error.message}. Please fix it.` })
      }
      onRestore={(artifact, version) =>
        setMessages((current) => [
          ...current,
          {
            id: crypto.randomUUID(),
            role: "assistant",
            parts: [
              {
                type: "tool-update_artifact",
                toolCallId: crypto.randomUUID(),
                state: "output-available",
                input: {
                  id: artifact.id,
                  description: `Restored from version ${artifact.versions.indexOf(version) + 1}`,
                  content: version.content,
                },
                output: null,
              },
            ],
          },
        ])
      }
    >
      <ChatThread
        busy={busy}
        composer={
          <PromptInput status={status} onStop={stop} onSubmit={({ text }) => sendMessage({ text })}>
            <PromptInputBody>
              <PromptInputTextarea />
              <PromptInputActions>
                <PromptInputSubmit />
              </PromptInputActions>
            </PromptInputBody>
          </PromptInput>
        }
      >
        {messages.map((message, index) => {
          if (message.role === "user") {
            return <ChatUserMessage key={message.id} id={message.id} text={textOf(message)} />
          }
          const streaming = busy && index === messages.length - 1
          return (
            <ChatAssistantMessage key={message.id} id={message.id} text={textOf(message)} streaming={streaming}>
              {message.parts.map((part, position) => {
                if (part.type === "text") {
                  return <Markdown key={position} text={part.text} streaming={part.state === "streaming"} />
                }
                if (!isToolUIPart(part)) return null
                const found = findVersion(artifacts, part.toolCallId)
                return found ? (
                  <ArtifactCard key={part.toolCallId} artifactId={found.artifact.id} versionId={part.toolCallId} />
                ) : null
              })}
            </ChatAssistantMessage>
          )
        })}
      </ChatThread>
    </ArtifactWorkspace>
  )
}

The tools on your server

Two tools are enough: one creates an artifact with a stable id, the other changes it. A change is either edits, find and replace pairs for small changes, or the whole content for a rewrite. Both stream as tool input, so the panel fills, or the edit types in place, while the model writes.

import { streamText, tool, type ModelMessage } from "ai"
import { z } from "zod"

export const artifactTools = {
  create_artifact: tool({
    description:
      "Create a standalone artifact the user will want to keep, edit or run: a web page, an SVG, a document or a code file. Write the complete content.",
    inputSchema: z.object({
      id: z.string().describe("A short, stable kebab-case id, used to update it later"),
      title: z.string(),
      kind: z.enum(["html", "svg", "markdown", "code"]),
      language: z.string().optional().describe("For code, such as ts or python"),
      content: z.string(),
    }),
    execute: async () => ({ ok: true }),
  }),
  update_artifact: tool({
    description: [
      "Change an existing artifact. Send either edits or content, never both.",
      "Use edits for small, local changes: each find is copied exactly from the current version, including whitespace, and must appear in it exactly once. Include a few surrounding lines when a snippet could repeat. Edits apply in order, each to the result of the one before, so a later edit can find text an earlier one wrote.",
      "Use content to rewrite the whole artifact when most of it or its structure changes.",
      "If an edit fails, the result says which one and why. Retry with a longer, unique find, or send the whole content.",
    ].join("\n"),
    inputSchema: z.object({
      id: z.string(),
      description: z.string().describe("What changed, in a few words"),
      edits: z
        .array(
          z.object({
            find: z.string().describe("Text copied exactly from the current version, unique within it"),
            replace: z.string().describe("The new text. Empty to delete."),
          })
        )
        .optional(),
      content: z.string().optional().describe("The whole new content, for a rewrite"),
    }),
  }),
}

export function respond(model: Parameters<typeof streamText>[0]["model"], messages: ModelMessage[]) {
  return streamText({ model, messages, tools: artifactTools })
}

A panel on its own

Show a saved artifact without a chat, for example on a share page. The panel keeps its own version, tab and compare state.

"use client"

import { ArtifactPanel } from "@/components/blocks/artifact/artifact"
import type { Artifact } from "@/components/blocks/artifact/artifacts"

export function SharedArtifact({ artifact }: { artifact: Artifact }) {
  return (
    <div className="h-dvh">
      <ArtifactPanel artifact={artifact} />
    </div>
  )
}

Anatomy

The parts you compose, from the outside in.

PartDescription
ArtifactWorkspaceThe layout: your chat as children, the panel beside it on wide screens and in a bottom sheet on narrow ones. It decides what is open, opens new artifacts and announces progress.
ArtifactCardThe card in a message that opens an artifact, or one version of it, and shows when it is being written, failed or open.
ArtifactPanelThe header with title, versions, tabs and actions, and the code, changes, preview or document below it.
ArtifactCode, ArtifactPreviewThe streaming code and diff view, and the sandboxed preview with its error card.
getArtifactsFromMessages, applyEditsReads create and update tool calls from AI SDK messages and returns artifacts with their versions. applyEdits applies find and replace edits the same way, so you can check an edit before telling the model it worked.
useArtifactWorkspaceOpens and closes artifacts from your own controls, for example a list of files in a sidebar.
PropTypeDefault
artifactsEvery artifact in the conversation, in order. Usually getArtifactsFromMessages(messages).
Artifact[]–
childrenThe chat, usually a ChatThread.
ReactNode–
openIdThe open artifact, when you control it. An id that isn't in artifacts counts as closed.
string | null–
defaultOpenIdThe artifact open at first, when uncontrolled.
string | nullnull
onOpenChangeCalled when an artifact opens or the panel closes.
(id: string | null) => void–
autoOpenOpen an artifact on wide screens when a version starts streaming. It opens once per version, never after you close it during that stream, and never moves focus.
booleantrue
defaultPanelSizeThe panel’s share of the width, in percent, when it opens. The chat keeps at least 320px and the panel at least 360px. Dragging the divider wins for the rest of the session.
number70
onRestoreShows Restore on older versions. Add the old content as a new version; nothing is deleted.
(artifact, version) => void–
onFixShows Fix it when the preview throws. error has message and line.
(artifact, version, error) => void–
actionsExtra header controls, such as Publish or Share.
(artifact) => ReactNode–
PropTypeDefault
artifactIdThe artifact to open. Renders nothing if it doesn't exist.
string–
versionIdThe version this card stands for, usually the tool call id. Clicking opens that version, and the card says which version it is.
string–

ArtifactPanel

Rendered for you inside ArtifactWorkspace. Use it directly to show an artifact without a chat.

PropTypeDefault
artifactWhat to show.
Artifact–
versionIdThe version shown, when you control it. null follows the latest version.
string | null–
onVersionChangeCalled when someone picks a version, with null for the latest.
(versionId: string | null) => void–
onCloseShows the close button and closes on Escape.
() => void–
fullscreen, onFullscreenChangeShows Expand and Show chat, and leaves expanded mode on Escape.
boolean, (fullscreen: boolean) => void–
onRestore, onFix, actionsThe same as on the workspace.
see ArtifactWorkspace–

Artifact

The data the components read.

PropTypeDefault
id, titleA stable id and the title shown in the header and card.
string–
kindhtml and svg get a sandboxed preview, markdown renders as a document, code shows code only.
"html" | "svg" | "markdown" | "code"–
language, filenameHighlighting and the download name for code.
string–
versionsOldest first. status is streaming, complete, stopped or error; note says what changed. edits lists the find and replace pairs a targeted update applied; content is always the full result.
{ id, content, status?, error?, note?, edits?, createdAt?, messageId? }[]–
PropTypeDefault
messagesuseChat messages. Tool calls named create_artifact and update_artifact, or their camelCase forms, become versions.
UIMessage[]–
options.streamingWhether the last message is still arriving. Without it, unfinished calls count as stopped.
booleanfalse
options.toolsYour own tool names. Create reads id, title, kind, language, filename, description and content. Update reads id, description and either content or edits.
{ create: string[]; update: string[] }–
update inputcontent rewrites the artifact. edits apply in order to the last finished version; each find must match exactly once, or the version fails with error saying which edit and why. If both are sent, content wins.
{ content: string } | { edits: { find: string; replace: string }[] }–
KeyAction
EnterOn a card, opens its artifact and moves focus to the panel title. On an open card, closes it.
EscLeaves expanded mode, then closes the panel and returns focus to the card.
←→Resize the split while the divider has focus. Past the smallest size, the panel closes or the chat hides.
←→Switch between Code and Preview while a tab has focus.
TabMoves through the header, the code or document area, which scrolls with the arrow keys, and the preview.
  • The panel is a labelled region with a real heading. Opening it from a card moves focus to that heading, and closing returns focus to the card. Opening on its own while the model writes never moves focus, so typing in the composer is never interrupted.
  • Cards are buttons with aria-pressed and aria-controls, and say whether the artifact is being written, failed or stopped. A polite status announces when writing starts and when a version is ready, instead of reading every line.
  • The preview iframe is titled after the artifact. Runtime errors appear as an alert with the message and line, and so does an edit that couldn’t be applied. Changes are announced as added and removed, not only shown in color.
  • Every icon button has a label and a tooltip. Opening, closing and expanding slide with the chat reflowing smoothly, and with reduced motion the layout changes at once with a short fade. Edits fade in whole instead of typing.

Code

12 files, added to components/blocks/artifact.