# Artifact Panel

> Give something an agent made, such as a file or a document, a surface beside the conversation that can be opened again.

Source: https://ai.honestui.com/docs/components/artifact-panel

```tsx
"use client"

import * as React from "react"
import { Check as CheckIcon, Copy as CopyIcon } from "honestui/icons"

import { Button } from "@/components/ui/button"
import { ArtifactCard, ArtifactPanel } from "@/components/ai/artifact-panel"
import { ChatMessage } from "@/components/ai/chat-thread"
import { CodeView } from "@/components/ai/code-view"

// Sample artifact.
const sampleCode = `type Row = { id: string; amount: number }

/** Rounds to cents once, after the rows are summed. */
function roundTotal(total: number) {
  return Math.round(total * 100) / 100
}

export function exportTotal(rows: Row[]) {
  const total = rows.reduce((sum, row) => sum + row.amount, 0)
  return roundTotal(total)
}`

const COPIED_MILLISECONDS = 1500

/** The panel's own Copy, since an embedded Code View has no header to hold one. */
function CopyButton({ text }: { text: string }) {
  const [state, setState] = React.useState<"idle" | "copied" | "failed">("idle")
  const resetTimer = React.useRef<number | undefined>(undefined)
  React.useEffect(() => () => window.clearTimeout(resetTimer.current), [])

  return (
    <>
      <Button
        variant="link"
        size="sm"
        onClick={async () => {
          try {
            await navigator.clipboard.writeText(text)
            setState("copied")
          } catch {
            setState("failed")
          }
          window.clearTimeout(resetTimer.current)
          resetTimer.current = window.setTimeout(() => setState("idle"), COPIED_MILLISECONDS)
        }}
      >
        {state === "copied" ? <CheckIcon aria-hidden="true" /> : <CopyIcon aria-hidden="true" />}
        Copy
      </Button>
      <span role="status" className={state === "failed" ? undefined : "sr-only"}>
        {state === "copied" ? "Copied" : state === "failed" ? "Copy failed" : ""}
      </span>
    </>
  )
}

/**
 * No agent wrote this file. The card in the reply opens the panel and shows
 * whether it is open, and closing the panel returns focus to the card.
 */
export function ArtifactPanelDemo() {
  const [isOpen, setIsOpen] = React.useState(true)
  // The first render shows the panel without a press, so it must not take focus.
  const [wasOpenedByPerson, setWasOpenedByPerson] = React.useState(false)
  const cardRef = React.useRef<HTMLButtonElement>(null)

  return (
    <div className="flex w-full max-w-xl min-w-0 flex-col gap-[var(--hui-space-4)]">
      <ChatMessage role="assistant" author="Billing agent">
        <p className="m-0 mb-[var(--hui-space-3)]">
          Done. The rows are summed first, and the total is rounded to cents at the end.
        </p>
        <ArtifactCard
          ref={cardRef}
          title="export.ts"
          description="TypeScript, 11 lines"
          isOpen={isOpen}
          onClick={() => {
            setWasOpenedByPerson(true)
            setIsOpen(!isOpen)
          }}
        />
      </ChatMessage>
      {isOpen && (
        <div className="h-[19rem] min-w-0">
          <ArtifactPanel
            title="export.ts"
            description="TypeScript, 11 lines"
            flush
            autoFocus={wasOpenedByPerson}
            actions={<CopyButton text={sampleCode} />}
            onClose={() => {
              setIsOpen(false)
              cardRef.current?.focus()
            }}
          >
            <CodeView embedded filename="export.ts" code={sampleCode} />
          </ArtifactPanel>
        </div>
      )}
    </div>
  )
}

```

## Overview [#overview]

Some replies are not messages. A file, a document, or a page the agent wrote is something a person returns to, and it gets lost when it scrolls away inside a thread. Artifact Panel gives it a surface of its own, and Artifact Card leaves a mention of it in the message, so it can be opened again.

The panel is a frame: a header with the title, a status, your actions, and Close, over a body that scrolls. What goes in the body is yours, such as a [Code View](/docs/components/code-view), a [Markdown](/docs/components/markdown) document, or a preview.

Neither component holds the artifact or decides when it shows. Your application keeps which artifact is open, renders the panel when one is, and places it in your layout.

## Anatomy [#anatomy]

<Anatomy title="ArtifactPanel">
  <AnatomyItem name="ArtifactPanel" description="The frame beside the conversation">
    <AnatomyItem name="Header" description="The title, a short description, the status, your actions, and Close" />

    <AnatomyItem name="Error" description="A line under the header when the artifact stopped early, with Try again" />

    <AnatomyItem name="Body" description="Your content, in a region that scrolls" />
  </AnatomyItem>

  <AnatomyItem name="ArtifactCard" description="The mention of the artifact inside a message, which opens the panel" />
</Anatomy>

Installing `artifact-panel` brings [Button](https://www.honestui.com/docs/components/button) along as a registry dependency.

## Behavior [#behavior]

### Status [#status]

`status` is `streaming`, `complete`, or `error`, and defaults to `complete`. Pass the same value to the panel and to the card.

* `streaming` shows "Writing…" in the panel's header and on the card.
* `complete` shows the description.
* `error` shows a line under the header saying the artifact stopped early, and the same sentence on the card. What was written so far stays in the body.

Passing `onRetry` adds Try again to the error line. Requesting the artifact again and resetting `status` are your application's work.

### Opening and closing [#opening-and-closing]

Artifact Card is a button. Give it an `onClick` that opens the panel, and pass `isOpen` so it can show which state it is in: "Open" when the panel is closed, "Showing" when it is open.

Passing `onClose` to the panel adds Close. A panel with no `onClose` has no Close button, for a layout where the panel is always present.

### Layout [#layout]

The panel fills its parent's width and height. Give the parent a height, or the body will grow with its content and never scroll.

Where the panel sits is your layout's decision. A common one is two columns on a wide screen, and the panel in place of the conversation on a narrow one, with Close leading back. The preview above is narrow, so it puts the panel under the message.

### Content that reaches the edges [#content-that-reaches-the-edges]

The body has padding, which suits a document. Set `flush` to remove it for content that should reach the panel's edges, such as code. A [Code View](/docs/components/code-view) with `embedded` drops its own frame and header, so the panel's header is the only one. An embedded Code View has no Copy button, so put your own in `actions`, as the preview does.

### Focus [#focus]

When a person opens the panel, set `autoFocus`, so the panel takes focus and a keyboard or screen reader user lands in what they asked for. When a reply opens the panel by itself, leave `autoFocus` off, so focus is not pulled out of the prompt the person is typing.

Closing the panel removes the focused button. In `onClose`, return focus to the card that opened it, as the preview does.

## Accessibility [#accessibility]

The panel is a region named by its title. "Writing…" is a status and the error line is an alert, so both are announced when they appear. The body can take focus, so it can be scrolled from the keyboard.

The card reports whether its panel is open through `aria-expanded`, and shows the same thing in words. Close and Try again are buttons with names.

The panel is not a dialog. It does not trap focus or close on <kbd>Escape</kbd>, because the conversation beside it stays usable. If your narrow layout covers the conversation with the panel, the conversation should be hidden from assistive technology while it is covered.

Every string comes from `labels`.

The preview has been checked with a keyboard and with axe. It has not been checked with a screen reader yet.

| Key                                  | Action                                                       |
| ------------------------------------ | ------------------------------------------------------------ |
| <kbd>Enter</kbd> or <kbd>Space</kbd> | Open the panel from its card, or activate Close or Try again |
| <kbd>Tab</kbd>                       | Move through the header's controls, then into the body       |
| Arrow keys                           | Scroll the focused body                                      |

## Installation [#installation]


  

  
    <CliBlock commands="[&#x22;@honestai/artifact-panel&#x22;]" />
  

  
    
      
        Add the component it is built from.
      

      <CliBlock commands="[&#x22;button&#x22;]" />

      
        Copy and paste this file into your project.
      

      ### components/ai/artifact-panel.tsx

```tsx
"use client"

import * as React from "react"
import {
  CircleAlert as CircleAlertIcon,
  FileText as FileTextIcon,
  LoaderCircle as LoaderCircleIcon,
  PanelRight as PanelRightIcon,
  X as CloseIcon,
} from "honestui/icons"

import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"

export type ArtifactStatus = "streaming" | "complete" | "error"

export type ArtifactLabels = {
  open: string
  showing: string
  close: string
  streaming: string
  error: string
  retry: string
}

export const defaultArtifactLabels: ArtifactLabels = {
  open: "Open",
  showing: "Showing",
  close: "Close",
  streaming: "Writing…",
  error: "This stopped before it finished.",
  retry: "Try again",
}

const smallText =
  "[font-size:var(--hui-font-size-small)] [letter-spacing:var(--hui-letter-spacing-small)] [line-height:var(--hui-line-height-small)]"

const miniText =
  "[font-size:var(--hui-font-size-mini)] [letter-spacing:var(--hui-letter-spacing-mini)] [line-height:var(--hui-line-height-mini)]"

export type ArtifactPanelProps = Omit<React.ComponentProps<"section">, "title"> & {
  /** Names the artifact, such as a file name or a document title. */
  title: string
  /** A short note under the title, such as a file type or a version. */
  description?: string
  /** Defaults to "complete". Set "streaming" while the artifact is being written. */
  status?: ArtifactStatus
  /** Controls in the header, such as Copy or Download. */
  actions?: React.ReactNode
  /** Passing it shows Close. */
  onClose?: () => void
  /** Passing it shows Try again when `status` is "error". */
  onRetry?: () => void
  /** Removes the body's padding, for content that reaches the edges, such as an embedded Code View. */
  flush?: boolean
  /** Moves focus to the panel when it mounts. Set it when a person opened the panel, not when a reply did. */
  autoFocus?: boolean
  labels?: Partial<ArtifactLabels>
}

/**
 * A surface for something an agent made, such as a file or a document, that
 * sits beside the conversation. It frames the content you pass and reports the
 * status you give it. Where it sits, and when it shows, are your layout's.
 */
function ArtifactPanel({
  title,
  description,
  status = "complete",
  actions,
  onClose,
  onRetry,
  flush = false,
  autoFocus = false,
  labels: labelsProp,
  className,
  children,
  ...props
}: ArtifactPanelProps) {
  const labels = { ...defaultArtifactLabels, ...labelsProp }
  const rootRef = React.useRef<HTMLElement>(null)
  const titleId = React.useId()

  React.useEffect(() => {
    if (autoFocus) rootRef.current?.focus()
    // Focus moves once, when the panel appears.
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [])

  return (
    <section
      ref={rootRef}
      aria-labelledby={titleId}
      aria-busy={status === "streaming"}
      tabIndex={-1}
      data-slot="artifact-panel"
      data-status={status}
      className={cn(
        "flex h-full min-h-0 w-full min-w-0 flex-col overflow-hidden rounded-[var(--hui-radius-5)] border-[0.5px] border-[var(--hui-color-border-base-primary)] bg-[var(--hui-color-background-base-primary)] text-[var(--hui-color-foreground-base-primary)] outline-none focus-visible:[outline:var(--hui-focus-ring)]",
        smallText,
        className
      )}
      {...props}
    >
      <div
        data-slot="artifact-panel-header"
        className="flex min-w-0 shrink-0 items-center gap-[var(--hui-space-3)] border-b-[0.5px] border-[var(--hui-color-border-base-primary)] py-[var(--hui-space-2)] ps-[var(--hui-space-4)] pe-[var(--hui-space-2)]"
      >
        <div className="min-w-0 flex-1">
          <p id={titleId} className="m-0 truncate [font-weight:var(--hui-font-weight-medium)]">
            {title}
          </p>
          {description && (
            <p className={cn("m-0 truncate text-[var(--hui-color-foreground-base-secondary)]", miniText)}>
              {description}
            </p>
          )}
        </div>
        {/* Always mounted, so the change to and from writing is announced. */}
        <span
          role="status"
          className="flex shrink-0 items-center gap-[var(--hui-space-2)] text-[var(--hui-color-foreground-base-secondary)]"
        >
          {status === "streaming" && (
            <>
              <LoaderCircleIcon aria-hidden="true" className="size-3.5 shrink-0 motion-safe:animate-spin" />
              {labels.streaming}
            </>
          )}
        </span>
        {(actions || onClose) && (
          <div className="flex shrink-0 items-center gap-[var(--hui-space-1)]">
            {actions}
            {onClose && (
              <Button variant="link" size="icon-sm" aria-label={labels.close} title={labels.close} onClick={onClose}>
                <CloseIcon aria-hidden="true" />
              </Button>
            )}
          </div>
        )}
      </div>

      {status === "error" && (
        <div
          role="alert"
          className="flex min-w-0 shrink-0 flex-wrap items-center gap-x-[var(--hui-space-3)] gap-y-[var(--hui-space-1)] border-b-[0.5px] border-[var(--hui-color-border-base-primary)] py-[var(--hui-space-2)] ps-[var(--hui-space-4)] pe-[var(--hui-space-2)]"
        >
          <span className="flex min-w-0 flex-1 items-start gap-[var(--hui-space-2)]">
            <CircleAlertIcon
              strokeWidth={1.5}
              strokeLinecap="round"
              aria-hidden="true"
              className="mt-px size-3.5 shrink-0 text-[var(--hui-color-foreground-danger-primary)]"
            />
            <span className="min-w-0 break-words">{labels.error}</span>
          </span>
          {onRetry && (
            <Button variant="link" size="sm" className="shrink-0" onClick={onRetry}>
              {labels.retry}
            </Button>
          )}
        </div>
      )}

      {/* Focusable so the content can be scrolled by keyboard. */}
      <div
        role="group"
        aria-labelledby={titleId}
        tabIndex={0}
        data-slot="artifact-panel-body"
        className={cn(
          "min-h-0 flex-1 overflow-auto outline-none focus-visible:[outline:var(--hui-focus-ring)] focus-visible:outline-offset-[var(--hui-focus-ring-offset-inset)]",
          !flush && "p-[var(--hui-space-4)]"
        )}
      >
        {children}
      </div>
    </section>
  )
}

export type ArtifactCardProps = Omit<React.ComponentProps<"button">, "children" | "title"> & {
  /** The same title as the panel it opens. */
  title: string
  /** A short note under the title. Replaced by the status while the artifact is unfinished. */
  description?: string
  status?: ArtifactStatus
  /** Whether this artifact's panel is showing. */
  isOpen?: boolean
  /** Replaces the document icon. */
  icon?: React.ReactNode
  labels?: Partial<ArtifactLabels>
}

/** The mention of an artifact inside a message. Pressing it is how its panel is opened again. */
function ArtifactCard({
  title,
  description,
  status = "complete",
  isOpen = false,
  icon,
  labels: labelsProp,
  className,
  ...props
}: ArtifactCardProps) {
  const labels = { ...defaultArtifactLabels, ...labelsProp }
  const note =
    status === "streaming" ? labels.streaming : status === "error" ? labels.error : description

  return (
    <button
      type="button"
      aria-expanded={isOpen}
      data-slot="artifact-card"
      data-status={status}
      className={cn(
        "flex w-full max-w-sm min-w-0 cursor-pointer items-center gap-[var(--hui-space-3)] rounded-[var(--hui-radius-4)] border-[0.5px] border-[var(--hui-color-border-base-primary)] bg-[var(--hui-color-background-base-primary)] px-[var(--hui-space-4)] py-[var(--hui-space-3)] text-start text-[var(--hui-color-foreground-base-primary)] shadow-[var(--hui-shadow-feather)] outline-none motion-safe:[transition:var(--hui-transition-interactive)] hover:bg-[var(--hui-color-background-base-primary-hover)] focus-visible:[outline:var(--hui-focus-ring)] aria-expanded:border-[var(--hui-color-border-accent-emphasis)]",
        smallText,
        className
      )}
      {...props}
    >
      <span
        aria-hidden="true"
        className="flex size-4 shrink-0 items-center justify-center text-[var(--hui-color-foreground-base-secondary)] [&_svg]:size-4"
      >
        {status === "streaming" ? (
          <LoaderCircleIcon className="motion-safe:animate-spin" />
        ) : status === "error" ? (
          <CircleAlertIcon strokeWidth={1.5} strokeLinecap="round" />
        ) : (
          (icon ?? <FileTextIcon />)
        )}
      </span>
      <span className="flex min-w-0 flex-1 flex-col">
        <span className="truncate [font-weight:var(--hui-font-weight-medium)]">{title}</span>
        {note && (
          <span className={cn("truncate text-[var(--hui-color-foreground-base-secondary)]", miniText)}>
            {note}
          </span>
        )}
      </span>
      <span
        className={cn(
          "flex shrink-0 items-center gap-[var(--hui-space-2)] text-[var(--hui-color-foreground-base-secondary)]",
          miniText
        )}
      >
        {isOpen ? labels.showing : labels.open}
        <PanelRightIcon aria-hidden="true" className="size-3.5 shrink-0" />
      </span>
    </button>
  )
}

export { ArtifactPanel, ArtifactCard }

```

      
        Update the import paths to match your project setup.
      
    
  


## Usage [#usage]

```tsx
import { ArtifactCard, ArtifactPanel } from "@/components/ai/artifact-panel"

// In the message that produced the artifact:
<ArtifactCard
  ref={cardRef}
  title={artifact.title}
  description={artifact.kind}
  status={artifact.status}
  isOpen={openArtifactId === artifact.id}
  onClick={() => setOpenArtifactId(artifact.id)}
/>

// Beside the conversation:
{openArtifact && (
  <ArtifactPanel
    title={openArtifact.title}
    description={openArtifact.kind}
    status={openArtifact.status}
    autoFocus
    onRetry={() => agent.rewrite(openArtifact.id)}
    onClose={() => {
      setOpenArtifactId(null)
      cardRef.current?.focus()
    }}
  >
    <Markdown>{openArtifact.text}</Markdown>
  </ArtifactPanel>
)}
```

### Code, with your own actions [#code-with-your-own-actions]

`actions` places controls in the header, before Close. For code, pair `flush` with an embedded Code View:

```tsx
<ArtifactPanel
  title={artifact.title}
  flush
  actions={
    <Button variant="link" size="sm" onClick={() => copy(artifact.text)}>
      Copy
    </Button>
  }
>
  <CodeView embedded filename={artifact.title} code={artifact.text} />
</ArtifactPanel>
```

## Examples [#examples]

### Stopped early [#stopped-early]

A draft that stopped before it finished. Try again stands in for a second attempt.

```tsx
"use client"

import * as React from "react"

import { ArtifactPanel, type ArtifactStatus } from "@/components/ai/artifact-panel"
import { Markdown } from "@/components/ai/markdown"

const partialText = "## Release note\n\nExport totals are now rounded once, after"
const fullText =
  "## Release note\n\nExport totals are now rounded once, after the rows are summed. Totals on 1,204 past invoices change by one cent."

/** No request is made. Try again swaps in the full text after a pause, as a second attempt would. */
export function ArtifactPanelErrorExample() {
  const [status, setStatus] = React.useState<ArtifactStatus>("error")
  const pending = React.useRef<number | undefined>(undefined)
  React.useEffect(() => () => window.clearTimeout(pending.current), [])

  return (
    <div className="h-64 w-full max-w-md min-w-0">
      <ArtifactPanel
        title="Release note"
        description="Draft"
        status={status}
        labels={{ error: "The connection dropped, so this draft is incomplete." }}
        onRetry={() => {
          setStatus("streaming")
          pending.current = window.setTimeout(() => setStatus("complete"), 900)
        }}
      >
        <Markdown isStreaming={status === "streaming"}>
          {status === "complete" ? fullText : partialText}
        </Markdown>
      </ArtifactPanel>
    </div>
  )
}

```

## API reference [#api-reference]

### ArtifactPanel [#artifactpanel]

Accepts native section props beyond those listed. Its children are the body.

| Prop          | Type                      | Default      | Description                                                           |
| ------------- | ------------------------- | ------------ | --------------------------------------------------------------------- |
| `title`       | string                    | Required     | Names the artifact.                                                   |
| `description` | string                    | None         | A short note under the title.                                         |
| `status`      | `ArtifactStatus`          | `"complete"` | Whether the artifact is being written, is finished, or stopped early. |
| `actions`     | ReactNode                 | None         | Controls in the header.                                               |
| `onClose`     | `() => void`              | None         | Shows Close.                                                          |
| `onRetry`     | `() => void`              | None         | Shows Try again when `status` is `"error"`.                           |
| `flush`       | boolean                   | `false`      | Removes the body's padding.                                           |
| `autoFocus`   | boolean                   | `false`      | Moves focus to the panel when it mounts.                              |
| `labels`      | `Partial<ArtifactLabels>` | English      | Control names and status text.                                        |

### ArtifactCard [#artifactcard]

Accepts native button props beyond those listed, including `onClick`.

| Prop          | Type                      | Default      | Description                                                |
| ------------- | ------------------------- | ------------ | ---------------------------------------------------------- |
| `title`       | string                    | Required     | The same title as the panel it opens.                      |
| `description` | string                    | None         | A short note under the title.                              |
| `status`      | `ArtifactStatus`          | `"complete"` | Replaces the description while the artifact is unfinished. |
| `isOpen`      | boolean                   | `false`      | Whether this artifact's panel is showing.                  |
| `icon`        | ReactNode                 | A document   | Shown before the title.                                    |
| `labels`      | `Partial<ArtifactLabels>` | English      | Control names and status text.                             |

### Types [#types]

| Export           | Shape                                                     |
| ---------------- | --------------------------------------------------------- |
| `ArtifactStatus` | `"streaming"`, `"complete"`, or `"error"`                 |
| `ArtifactLabels` | `open`, `showing`, `close`, `streaming`, `error`, `retry` |
