ArtifactPanel
The panel that holds what an agent generates — code, a document, a live preview — beside the conversation. A resizable, non-modal split on desktop and a modal sheet on mobile, with a preview / source toggle, versions, copy and download.
pnpm add @tessinaui/uiWhen to use
- Something the reader will keep, run or edit. A file, a document or a page an agent produced needs more room than a message.
- Long or interactive output. Anything that would hurt the scannability of the thread if it sat inline.
- A reader who moves between the result and its source, or between iterations of it: the Preview | Source toggle and the version switcher are built for that.
- Output the reader will copy or download. The panel keeps those actions in its header, apart from the message actions.
- A conversation that must stay usable. On desktop the panel is a non-modal split, so the chat is still there while the artifact is open.
When not to use
- A short snippet inside an answer. Use CodeBlock in the message.
- A tool call and its arguments. Use ToolCall.
- A general side sheet that is not an artifact. Use Drawer.
- A one-line or purely conversational reply. There is nothing to open; leave it in the thread.
- Anything the reader must act on before the conversation continues. A non-modal panel does not interrupt; use Modal.
Installation
pnpm add @tessinaui/uiUsage
import { ArtifactPanel } from "@tessinaui/ui/artifact-panel";
<div className="flex h-[600px]">
<Conversation className="min-w-0 flex-1" />
<ArtifactPanel
title="price-tag.tsx"
source={source}
language="tsx"
preview={<PriceTagPreview />}
status={streaming ? "streaming" : "idle"}
/>
</div>The panel is one child of a flex row you own. Put the conversation beside it
with min-w-0 flex-1 so the chat gives way as the panel grows. Like the rest of
the AI kit, the panel is stream-fed: pass the source as it arrives, and any
prefix of any prop renders.
Desktop and mobile
From md (768px) up the panel is an in-flow region beside the chat. It is
non-modal: it traps nothing, dims nothing and takes no focus when it opens,
so the conversation stays usable. Below md the same panel is a modal
sheet — a 375px split leaves room for neither side. The sheet takes focus,
traps it, and closes on Escape, the close button or a swipe. mode="desktop" or
mode="mobile" forces either.
Unless you pass defaultOpen or open, the desktop panel starts open and the
sheet starts closed: a modal sheet is opened by a tap, never by a page load. To
reopen it, control open from the element in the chat that represents the
artifact:
const [open, setOpen] = React.useState(false);
<button aria-expanded={open} aria-controls="artifact" onClick={() => setOpen(true)}>
price-tag.tsx
</button>
<ArtifactPanel id="artifact" open={open} onOpenChange={setOpen} … />Opening and closing
Closed, the desktop panel stays in the page: it is inert, takes no width in the
row and keeps its content, so reopening shows the code where the reader left
it, scroll position included. Opening and closing move on the panel curve —
the space it takes in the row widens while the panel slides in from its end
edge, and the conversation gives way as it goes. Resizing never animates, and
with reduced motion the panel opens and closes at once. A row with a gap
keeps that gap beside a closed panel; pad the conversation instead.
In a narrow row
Name the row @container/artifact-panel and make it relative. Where the row
is under 48rem — too narrow for the panel and a readable conversation — the open
panel lies over the conversation from the end edge instead of squeezing it. It
is still non-modal: nothing behind it dims and focus is never trapped. The
mobile sheet still follows the viewport, not the row.
<div className="@container/artifact-panel relative flex h-[600px]">
<Conversation className="min-w-0 flex-1" />
<ArtifactPanel … />
</div>Examples
Default
A conversation with an artifact card that opens the panel. Drag the panel's left edge, or focus it and use the arrow keys.
Empty
Before anything is generated the body says where code will appear. Copy and
Download stay in place and say they are unavailable until there is a source.
Change the words with strings.emptyTitle and strings.emptyDescription.
Embedded
variant="embedded" is a quiet document slot inside a layout that already owns
opening and closing, such as a template or a card. It has one title row, the
source as a framed code block with its own Copy, and a dashed empty state. There
is no close button, toolbar, resize edge or panel plate, so headerActions,
resizable and the width props have nothing to act on. Change the empty
state's glyph with emptyIcon.
Narrow row
The row is under 48rem, so the panel opens over the conversation instead of beside it.
Preview and source
With preview, the panel shows a Preview | Source tablist and opens on
Preview. Copy and download always take the source.
Versions
version and versionCount show a previous / next switcher. Swap the source
(and preview) when onVersionChange fires — the panel stays put, and so does
focus.
Streaming
While status is "streaming" the body shows a placeholder until the first
characters arrive, then the source grows in place. Stop sets "interrupted",
which keeps what arrived.
States
idle, streaming, interrupted and failed, each with a word and a glyph.
A failure shows its message in a notice above the content, so the last good
version stays in view, and with onRetry the notice carries a Retry button.
After Retry, focus moves to the content, where the new version arrives.
Header actions
headerActions adds your own controls at the end of the toolbar — share, a
full-width toggle, an export menu.
Mobile sheet
mode="mobile" shows the sheet at any width. It is 90% of the viewport tall,
so the conversation stays visible behind it.
On color
The panel is a page surface. Inside a coloured Surface it keeps its own paint
and hands its content the page's tone.
RTL
In a right-to-left layout the chat is on the right, so the resize edge moves to
the panel's right side and → widens it. Strings switch to Arabic only for an
explicit dir="rtl".
API Reference
| Prop | Type | Default | Description |
|---|---|---|---|
title | string | — | Names the region on desktop and the dialog on mobile |
description | ReactNode | — | A meta line under the title: language, size, "Saved" |
source | string | — | The artifact's text. Streamed; any prefix renders. Copied and downloaded as-is |
language | string | "text" | Syntax language for the source view |
filename | string | — | Names the source view's code region |
preview | ReactNode | — | The rendered artifact. Present, the panel shows Preview | Source |
view · defaultView · onViewChange | "preview" | "source" | "preview" with a preview | The view on screen |
version · versionCount · onVersionChange | number | — | The version switcher. Under two versions renders none |
downloadFilename | string | — | Shows Download and saves the source under this name |
onDownload | () => void | — | Replaces the built-in download |
writeToClipboard | (text) => boolean | Promise<boolean> | — | Replaces the clipboard write |
status | "idle" | "streaming" | "interrupted" | "failed" | "idle" | Where the stream is. An unknown value reads as idle |
error | ReactNode | — | The failure notice's message |
onRetry | () => void | — | Shows Retry in the failure notice |
open · defaultOpen · onOpenChange | boolean | open on desktop, closed on mobile | Whether the panel or sheet shows |
mode | "desktop" | "mobile" | by viewport | Forces the split or the sheet |
resizable | boolean | { min?, max? } | true | false fixes the width. Min 280px, max half the viewport |
width · defaultWidth · onWidthChange | number | 420 | Width in px. onWidthChange is the hook for persisting it |
variant | "panel" | "embedded" | "panel" | panel is the full panel with toolbar, close and resize edge. embedded is a quiet document slot: one title row, a framed code block with its own Copy, a dashed empty state, and no close, toolbar or resize edge |
emptyIcon | ReactNode | a document | The empty state's glyph |
headerActions | ReactNode | — | Extra controls at the end of the toolbar |
dir | "ltr" | "rtl" | inherited | Text direction; only an explicit "rtl" picks the Arabic strings |
strings | Partial<ArtifactPanelStrings> | — | Per-entry string overrides, the empty state's emptyTitle and emptyDescription included |
Everything else lands on the root: the <section> on desktop, the sheet on
mobile. Pass id to point a trigger's aria-controls at it. On desktop the
section sits inside a track that takes its place in the row; the section is in
the page even while closed, so aria-controls always resolves.
DOM contract
| Attribute | Where | Values |
|---|---|---|
data-slot | every part | artifact-panel-track, artifact-panel, artifact-panel-rail, artifact-panel-rail-steppers, artifact-panel-header, artifact-panel-description, artifact-panel-status, artifact-panel-toolbar, artifact-panel-versions, artifact-panel-copy, artifact-panel-download, artifact-panel-close, artifact-panel-body, artifact-panel-content, artifact-panel-placeholder, artifact-panel-empty, artifact-panel-error, artifact-panel-preview, artifact-panel-code, artifact-panel-announcer |
data-mode | the root | desktop · mobile |
data-state | the desktop root | open · closed |
data-open | the desktop track | present while open |
data-status | the root, the status line | idle · streaming · interrupted · failed |
Accessibility
- Desktop is a named region, not a dialog. It is a
<section>named by its title, so a screen-reader user can jump to it by landmark. It traps nothing and takes no focus when it opens. When it closes from inside, focus goes back to the element that had it when the panel opened — never to the page. - Mobile is a dialog. The sheet is a modal dialog named by the same title; focus moves in, stays in, and returns when it closes.
- The resize edge is a window splitter. It is a focusable
separatornamedResize {title}, reporting its width in pixels. The arrow key toward the conversation widens the panel by 16px and the other narrows it; Home and End jump to the minimum and maximum; Enter collapses to the minimum and restores. In a right-to-left layout the keys mirror. - Dragging has a tap alternative. Widen and narrow buttons appear beside the edge — on hover with a mouse, always on a touch screen — and each is a 44px target. They step like the arrow keys.
- The toggle says which view is shown. Preview and Source are tabs, so the selected one is announced, and each view is a named tab panel.
- A version change is announced. The counter reads "2 / 3" on screen and "Version 2 of 3" to a screen reader; changing it is announced once. The buttons at either end stay focusable and say they are unavailable.
- Transitions speak; tokens do not. While the source streams, the content is marked busy and silent. One polite region announces that generation started, finished, stopped or failed — and the result of a copy.
- State is never colour alone. Each status has a word and a glyph.
- A closed panel is out of reach. On desktop it stays in the page but is inert, so neither Tab nor a screen reader lands in it.
- The failure speaks once. The notice is silent itself; the panel's own region announces the failure. After Retry, focus moves to the content — never to the page.
- Unavailable actions keep focus. With no source, Copy and Download say they are unavailable and stay focusable.
AT matrix: not yet screen-reader tested, hence beta.
Notes
- Pass
previewfrom the first render if the artifact will have one. The tablist arriving later rebuilds the body once. - Escape does not close the desktop panel. A split view is part of the page, not a popup; the close button does. The mobile sheet closes on Escape.
- No skeleton. The streaming state is the loading state: the placeholder shows until the first characters arrive.
- Width is yours to keep. The panel persists nothing; save
onWidthChangeif you want the width to survive a reload. - Name a row that can get narrow. In a row named
@container/artifact-panelthe panel lies over the conversation below 48rem. An unnamed row too narrow for the panel and the conversation squeezes the panel to fit rather than spilling, and the conversation is left with almost nothing.
In templates
- AI Chat Page — the panel beside a full-page thread, empty until the assistant writes code.
- AI Side Panel — the same dock motion for an assistant panel beside a page.