Artifact
AIProA panel for something the model generated, such as a document, a chart or code, with versions, Preview and Code tabs, copy, download and full screen, and a streaming state.
npx prodular-ui add artifactPart of Prodular UI Pro. Preview and read the docs freely, install with a license. See pricing
Document with versions
Step through versions, switch to the Markdown source, copy, download or open it full screen. Regenerate writes a new version.
Q3 vendor spend summary
Shorter, for the board pack · from 1,284 transactions
Q3 vendor spend
Q3 vendor spend was $412,800, up 6% on Q2. Cloud drove most of the increase. Northbeam Legal rose 31% on the MSA review and renews in November.
| Vendor | Q3 spend | vs Q2 |
|---|---|---|
| Lumen Cloud Services | $128,400 | +14% |
| Halcyon Freight | $96,250 | +2% |
| Brightline Payroll | $61,900 | 0% |
| Northbeam Legal | $38,100 | +31% |
"use client"
import * as React from "react"import { RefreshCwIcon } from "lucide-react"
import { Artifact, ArtifactActions, ArtifactCode, ArtifactCopyButton, ArtifactDescription, ArtifactDownloadButton, ArtifactFooter, ArtifactFullscreenButton, ArtifactHeader, ArtifactName, ArtifactPanel, ArtifactTab, ArtifactTabs, ArtifactTitle, ArtifactVersions, type ArtifactStatus,} from "@/components/ui/artifact"import { Button } from "@/components/ui/button"
type Version = { id: string; note: string; rows: [string, string, string][]; summary: string }
const base: Version[] = [ { id: "v1", note: "First draft", summary: "Q3 vendor spend was $412,800, up 6% on Q2.", rows: [ ["Lumen Cloud Services", "$128,400", "+14%"], ["Halcyon Freight", "$96,250", "+2%"], ["Brightline Payroll", "$61,900", "0%"], ], }, { id: "v2", note: "Added contract renewals", summary: "Q3 vendor spend was $412,800, up 6% on Q2. Two contracts renew in November.", rows: [ ["Lumen Cloud Services", "$128,400", "+14%"], ["Halcyon Freight", "$96,250", "+2%"], ["Brightline Payroll", "$61,900", "0%"], ["Northbeam Legal", "$38,100", "+31%"], ], }, { id: "v3", note: "Shorter, for the board pack", summary: "Q3 vendor spend was $412,800, up 6% on Q2. Cloud drove most of the increase. Northbeam Legal rose 31% on the MSA review and renews in November.", rows: [ ["Lumen Cloud Services", "$128,400", "+14%"], ["Halcyon Freight", "$96,250", "+2%"], ["Brightline Payroll", "$61,900", "0%"], ["Northbeam Legal", "$38,100", "+31%"], ], },]
function toMarkdown(v: Version) { return [ "# Q3 vendor spend", "", v.summary, "", "| Vendor | Q3 spend | vs Q2 |", "| --- | --- | --- |", ...v.rows.map((r) => `| ${r.join(" | ")} |`), "", ].join("\n")}
export default function ArtifactDocument() { const [versions, setVersions] = React.useState(base) const [current, setCurrent] = React.useState("v3") const [status, setStatus] = React.useState<ArtifactStatus>("ready") const version = versions.find((v) => v.id === current) ?? versions[0] const markdown = toMarkdown(version)
const regenerate = () => { setStatus("updating") setTimeout(() => { const next = `v${versions.length + 1}` setVersions((all) => [ ...all, { ...all[all.length - 1], id: next, note: "Regenerated with October actuals", summary: `${all[all.length - 1].summary} October is tracking 4% under plan.`, }, ]) setCurrent(next) setStatus("ready") }, 2200) }
return ( <Artifact status={status} className="mx-auto max-w-2xl self-start"> <ArtifactHeader> <ArtifactTitle model="Atlas 2"> <ArtifactName>Q3 vendor spend summary</ArtifactName> <ArtifactDescription> {version.note} · from 1,284 transactions </ArtifactDescription> </ArtifactTitle> <ArtifactVersions versions={versions.map((v) => v.id)} value={current} onValueChange={setCurrent} /> <ArtifactTabs> <ArtifactTab value="preview">Preview</ArtifactTab> <ArtifactTab value="code">Markdown</ArtifactTab> </ArtifactTabs> <ArtifactActions> <ArtifactCopyButton value={markdown} /> <ArtifactDownloadButton filename="q3-vendor-spend.md" content={markdown} mimeType="text/markdown" /> <ArtifactFullscreenButton /> </ArtifactActions> </ArtifactHeader> <ArtifactPanel value="preview" className="px-6 py-5"> <h2 className="text-lg font-medium">Q3 vendor spend</h2> <p className="mt-2 max-w-prose text-sm leading-relaxed text-muted-foreground">{version.summary}</p> <table className="mt-4 w-full text-sm"> <thead> <tr className="border-b text-left text-[13px] text-muted-foreground"> <th className="py-2 font-normal">Vendor</th> <th className="py-2 text-right font-normal">Q3 spend</th> <th className="py-2 text-right font-normal">vs Q2</th> </tr> </thead> <tbody className="divide-y"> {version.rows.map(([vendor, spend, change]) => ( <tr key={vendor}> <td className="py-2">{vendor}</td> <td className="py-2 text-right tabular-nums">{spend}</td> <td className="py-2 text-right text-muted-foreground tabular-nums">{change}</td> </tr> ))} </tbody> </table> </ArtifactPanel> <ArtifactPanel value="code"> <ArtifactCode code={markdown} /> </ArtifactPanel> <ArtifactFooter> <span>Edited versions are kept for 30 days</span> <Button size="xs" variant="ghost" className="ms-auto" disabled={status !== "ready"} onClick={regenerate} > <RefreshCwIcon data-icon="inline-start" /> Regenerate </Button> </ArtifactFooter> </Artifact> )}Streaming
While the model writes, the description shimmers and a thin AI line runs along the top of the panel.
Labor hours by region, October
Querying work orders
- Northeast
- Midwest
- South
- Mountain
- Pacific
Rows appear as the query returns them.
"use client"
import * as React from "react"import { RotateCcwIcon } from "lucide-react"
import { Artifact, ArtifactActions, ArtifactCode, ArtifactCopyButton, ArtifactDescription, ArtifactFullscreenButton, ArtifactHeader, ArtifactName, ArtifactPanel, ArtifactTab, ArtifactTabs, ArtifactTitle,} from "@/components/ui/artifact"import { Button } from "@/components/ui/button"
const regions = [ { region: "Northeast", hours: 412, sla: 96 }, { region: "Midwest", hours: 365, sla: 91 }, { region: "South", hours: 498, sla: 88 }, { region: "Mountain", hours: 214, sla: 97 }, { region: "Pacific", hours: 331, sla: 93 },]
const sql = `select region, sum(labor_minutes) / 60 as hours, round(avg(case when closed_at <= due_at then 1 else 0 end) * 100) as slafrom work_orderswhere closed_at >= date '2026-10-01'group by regionorder by region;`
export default function ArtifactStreaming() { const [shown, setShown] = React.useState(0) const [run, setRun] = React.useState(0) const total = regions.length + 1 const streaming = shown < total
React.useEffect(() => { setShown(0) const id = setInterval(() => { setShown((s) => { if (s >= total) { clearInterval(id) return s } return s + 1 }) }, 650) return () => clearInterval(id) }, [run, total])
const rows = regions.slice(0, Math.max(shown - 1, 0)) const max = Math.max(...regions.map((r) => r.hours)) const codeLines = sql.split("\n") const code = codeLines.slice(0, Math.ceil((codeLines.length * shown) / total)).join("\n")
return ( <Artifact status={streaming ? "streaming" : "ready"} className="mx-auto max-w-2xl self-start"> <ArtifactHeader> <ArtifactTitle> <ArtifactName>Labor hours by region, October</ArtifactName> <ArtifactDescription streamingLabel="Querying work orders"> 5 regions · 1,820 hours </ArtifactDescription> </ArtifactTitle> <ArtifactTabs> <ArtifactTab value="preview">Chart</ArtifactTab> <ArtifactTab value="code">SQL</ArtifactTab> </ArtifactTabs> <ArtifactActions> <ArtifactCopyButton value={sql} disabled={streaming} /> <ArtifactFullscreenButton /> <Button variant="ghost" size="icon-sm" aria-label="Run again" onClick={() => setRun((r) => r + 1)}> <RotateCcwIcon /> </Button> </ArtifactActions> </ArtifactHeader> <ArtifactPanel value="preview" className="min-h-64 px-5 py-4"> <ul className="flex flex-col gap-3" aria-label="Labor hours by region"> {regions.map((r, i) => { const ready = i < rows.length return ( <li key={r.region} className="grid grid-cols-[6rem_minmax(0,1fr)_4.5rem] items-center gap-3 text-sm"> <span className={ready ? "" : "text-muted-foreground"}>{r.region}</span> <span className="h-5 overflow-hidden rounded-sm bg-muted"> <span className="block h-full rounded-sm bg-chart-1 transition-[width] duration-240 ease-out motion-reduce:transition-none" style={{ width: ready ? `${(r.hours / max) * 100}%` : "0%" }} /> </span> <span className="text-right text-[13px] text-muted-foreground tabular-nums"> {ready ? `${r.hours} h` : ""} </span> </li> ) })} </ul> <p className="mt-4 text-[13px] text-muted-foreground"> {streaming ? "Rows appear as the query returns them." : "South has the most hours and the lowest SLA at 88%."} </p> </ArtifactPanel> <ArtifactPanel value="code"> <ArtifactCode code={code || " "} /> </ArtifactPanel> </Artifact> )}Anatomy
<Artifact status="ready"> <ArtifactHeader> <ArtifactTitle> <ArtifactName>Q3 vendor spend summary</ArtifactName> <ArtifactDescription>From 1,284 transactions</ArtifactDescription> </ArtifactTitle> <ArtifactVersions versions={["v1", "v2", "v3"]} value={v} onValueChange={setV} /> <ArtifactTabs> <ArtifactTab value="preview">Preview</ArtifactTab> <ArtifactTab value="code">Code</ArtifactTab> </ArtifactTabs> <ArtifactActions> <ArtifactCopyButton value={source} /> <ArtifactDownloadButton filename="summary.md" content={source} /> <ArtifactFullscreenButton /> </ArtifactActions> </ArtifactHeader> <ArtifactPanel value="preview">{/* rendered */}</ArtifactPanel> <ArtifactPanel value="code"><ArtifactCode code={source} /></ArtifactPanel></Artifact>Guidance
Use it when
- Use it when the model produces a self contained result people keep, edit or export: a report, a chart, a query, a template.
- Keep every version and let people step back with ArtifactVersions. Name what changed in ArtifactDescription.
- Offer a Code or Source tab whenever the preview is rendered from text, so people can copy the real thing.
- Use status="streaming" for the first version and status="updating" while a new version replaces the current one.
Avoid
- Wrapping it in another card. It is the container.
- Replacing a version in place. Add a new one.
- Short answers. A sentence or a number belongs in the message, not an artifact.
Rules for AI agents
Published in llms-full.txt, the registry and the MCP server, so coding agents follow them when they build with this component.
- Pass status on every render: streaming while the first version arrives, updating while a new version is written, ready when done, failed on error.
- Keep versions immutable. Append a new id to versions and select it; never mutate the content of an existing version.
- Give every icon action a label through ArtifactAction, ArtifactCopyButton, ArtifactDownloadButton or ArtifactFullscreenButton. Copy and download the source text, not the rendered HTML.
- Disable copy and download while streaming, or copy only what has arrived and say so.
- Use ArtifactPanel values that match ArtifactTab values, and default to the preview tab.
API
Artifact
| Prop | Type | Default | Description |
|---|---|---|---|
| status | "streaming" | "updating" | "ready" | "failed" | "ready" | Exposed as data-status. Drives the shimmer, the progress line and aria-busy. |
| defaultTab / tab / onTabChange | string | "preview" | The active tab, uncontrolled or controlled. |
ArtifactTitle
| Prop | Type | Default | Description |
|---|---|---|---|
| model | string | The model that produced it, in the AI mark tooltip. |
ArtifactDescription
| Prop | Type | Default | Description |
|---|---|---|---|
| streamingLabel / updatingLabel | string | "Generating" / "Writing a new version" | Replaces the description while busy. |
ArtifactVersions
| Prop | Type | Default | Description |
|---|---|---|---|
| versions / value / onValueChange | string[] / string / (v: string) => void | Version ids, oldest first. |
ArtifactAction
| Prop | Type | Default | Description |
|---|---|---|---|
| label | string | Tooltip and accessible name of an icon action. |
ArtifactDownloadButton
| Prop | Type | Default | Description |
|---|---|---|---|
| filename / content / mimeType | string / string | Blob / string | Downloads the content as a file. |
ArtifactCode
| Prop | Type | Default | Description |
|---|---|---|---|
| code / lineNumbers | string / boolean | lineNumbers true | Source view for the code tab. |
Installation
The Prodular UI CLI adds the source to components/ui with everything it depends on. In a project that already uses shadcn/ui, the shadcn CLI installs it from the Prodular registry too. See Installation for the first setup.
npx prodular-ui add artifact# or, with the registry in components.jsonnpx shadcn@latest add @prodular/artifact