A side panel for things an assistant generates: Preview and Code tabs, version stepping, copy and download, a streaming state and a close button, with the rendered result in your own slot.
Counter button
React component
"use client"
import { FileCode2 } from "lucide-react"
import { ArtifactPanel } from "@/components/ballmac/artifact-panel"
const code = `export function Counter() {
const [n, setN] = useState(0)
return (
<button onClick={() => setN(n + 1)}>
Clicked {n} times
</button>
)
}`
export default function ArtifactPanelDemo() {
return (
<div className="h-[20rem] w-full max-w-xl">
<ArtifactPanel
className="h-full"
title="Counter button"
kind="React component"
icon={<FileCode2 />}
code={code}
filename="counter.tsx"
language="tsx"
versions={3}
onClose={() => {}}
>
<div className="flex h-full min-h-48 items-center justify-center">
<span className="rounded-lg border bg-background px-5 py-2.5 text-sm font-medium shadow-xs">Clicked 0 times</span>
</div>
</ArtifactPanel>
</div>
)
}Installation
$ pnpm dlx shadcn@latest add @ballmac/artifact-panelInstall the dependencies.
$ pnpm add motion@^12 lucide-react radix-uiAdd the Ballmac items it builds on.
$ pnpm dlx shadcn@latest add @ballmac/i18nCopy the source into your project.
components/ballmac/artifact-panel.tsx// Ballmac UI: Artifact Panel. https://ui.ballmac.com/components/artifact-panel "use client" import * as React from "react" import { Check, ChevronLeft, ChevronRight, Copy, Download, X } from "lucide-react" import { motion, useReducedMotion } from "motion/react" import { Tabs as TabsPrimitive } from "radix-ui" import { cn } from "@/lib/utils" import { useMessages } from "@/lib/ballmac/i18n" type ArtifactPanelProps = Omit<React.ComponentProps<"section">, "title" | "onChange"> & { /** Name of the generated thing, such as "Pricing table". */ title: string /** Kind of artifact, shown under the title, such as "React component". */ kind?: string /** Icon in the header tile. */ icon?: React.ReactNode /** Source text. Shown in the Code tab and used for Copy and Download. */ code?: string /** File name for Download and the code header, such as "pricing-table.tsx". */ filename?: string /** Language label for the code, such as "tsx". */ language?: string /** The rendered result. Put an iframe, a component or a document here. */ children?: React.ReactNode /** How many versions exist. With more than one, arrows to step through them appear. */ versions?: number /** Which version is shown, starting at 1 (controlled). */ version?: number /** Called when the version changes. */ onVersionChange?: (version: number) => void /** True while the model is still writing. Shows a progress edge and disables copy and download. */ streaming?: boolean /** Tab shown first. */ defaultTab?: "preview" | "code" /** Adds a close button. */ onClose?: () => void /** Called after the code was copied. */ onCopy?: () => void /** Extra header actions, placed before Copy. */ actions?: React.ReactNode /** Classes for the preview canvas. */ previewClassName?: string } const iconButton = "inline-flex size-8 shrink-0 items-center justify-center rounded-md text-muted-foreground outline-none transition-colors hover:bg-accent hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-40 [&_svg]:size-4" function ArtifactPanel({ title, kind, icon, code, filename, language, children, versions = 1, version: versionProp, onVersionChange, streaming = false, defaultTab = "preview", onClose, onCopy, actions, previewClassName, className, ...props }: ArtifactPanelProps) { const msg = useMessages() const reduce = useReducedMotion() const [internalVersion, setInternalVersion] = React.useState(versions) const version = Math.min(Math.max(versionProp ?? internalVersion, 1), versions) const [copied, setCopied] = React.useState(false) const timer = React.useRef<ReturnType<typeof setTimeout>>(undefined) React.useEffect(() => () => clearTimeout(timer.current), []) const hasCode = code !== undefined const hasPreview = children !== undefined && children !== null function go(next: number) { const clamped = Math.min(Math.max(next, 1), versions) setInternalVersion(clamped) onVersionChange?.(clamped) } async function copy() { if (!code) return try { await navigator.clipboard.writeText(code) } catch { return } setCopied(true) onCopy?.() clearTimeout(timer.current) timer.current = setTimeout(() => setCopied(false), 1800) } function download() { if (!code) return const url = URL.createObjectURL(new Blob([code], { type: "text/plain;charset=utf-8" })) const a = document.createElement("a") a.href = url a.download = filename ?? "artifact.txt" a.click() URL.revokeObjectURL(url) } const tabClass = "relative inline-flex h-7 items-center rounded-md px-3 text-[13px] font-medium text-muted-foreground outline-none transition-colors hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-xs" return ( <section data-slot="artifact-panel" aria-label={title} aria-busy={streaming || undefined} className={cn( "relative flex w-full flex-col overflow-hidden rounded-xl border bg-card text-card-foreground shadow-xs", className )} {...props} > <TabsPrimitive.Root defaultValue={hasPreview ? defaultTab : "code"} className="flex min-h-0 flex-1 flex-col"> <header className="relative flex flex-wrap items-center gap-x-3 gap-y-2 border-b px-3 py-2.5"> <div className="flex min-w-[10rem] flex-1 items-center gap-2.5"> {icon && ( <span aria-hidden="true" className="flex size-8 shrink-0 items-center justify-center rounded-lg border bg-muted text-foreground [&_svg]:size-4" > {icon} </span> )} <div className="min-w-0"> <h3 className="truncate text-sm leading-5 font-semibold text-foreground">{title}</h3> {(kind || streaming) && ( <p className="truncate text-xs leading-4 text-muted-foreground"> {streaming ? "Writing…" : kind} </p> )} </div> </div> {hasPreview && hasCode && ( <TabsPrimitive.List aria-label={msg("artifact-panel.view", "View")} className="inline-flex items-center gap-0.5 rounded-lg bg-muted p-0.5" > <TabsPrimitive.Trigger value="preview" className={tabClass}> Preview </TabsPrimitive.Trigger> <TabsPrimitive.Trigger value="code" className={tabClass}> Code </TabsPrimitive.Trigger> </TabsPrimitive.List> )} <div className="flex items-center gap-0.5"> {versions > 1 && ( <span className="me-1 inline-flex items-center" role="group" aria-label={msg("artifact-panel.versions", "Versions")}> <button type="button" aria-label={msg("artifact-panel.previousVersion", "Previous version")} disabled={version <= 1 || streaming} onClick={() => go(version - 1)} className={iconButton} > <ChevronLeft aria-hidden="true" className="rtl:rotate-180"/> </button> <span className="min-w-12 text-center font-mono text-xs text-muted-foreground tabular-nums" aria-live="polite"> v{version} <span aria-hidden="true">/ {versions}</span> <span className="sr-only"> {msg("artifact-panel.versionOf", "of {total}", { total: versions })}</span> </span> <button type="button" aria-label={msg("artifact-panel.nextVersion", "Next version")} disabled={version >= versions || streaming} onClick={() => go(version + 1)} className={iconButton} > <ChevronRight aria-hidden="true" className="rtl:rotate-180"/> </button> </span> )} {actions} {hasCode && ( <> <button type="button" aria-label={copied ? msg("artifact-panel.copied", "Copied") : msg("artifact-panel.copyCode", "Copy code")} title={copied ? msg("artifact-panel.copied", "Copied") : msg("artifact-panel.copyCode", "Copy code")} disabled={streaming} onClick={copy} className={iconButton} > {copied ? <Check aria-hidden="true" /> : <Copy aria-hidden="true" />} </button> <button type="button" aria-label={msg("artifact-panel.downloadFile", "Download {name}", { name: filename ?? msg("artifact-panel.file", "file") })} title={msg("artifact-panel.download", "Download")} disabled={streaming} onClick={download} className={iconButton} > <Download aria-hidden="true" /> </button> </> )} {onClose && ( <button type="button" aria-label={msg("artifact-panel.closeArtifact", "Close artifact")} title={msg("artifact-panel.close", "Close")} onClick={onClose} className={iconButton}> <X aria-hidden="true" /> </button> )} </div> {streaming && ( <span aria-hidden="true" className="absolute inset-x-0 bottom-[-1px] h-0.5 overflow-hidden"> {reduce ? ( <span className="block h-full w-full bg-foreground/30" /> ) : ( <motion.span className="block h-full w-1/3 bg-foreground/70" animate={{ x: ["-100%", "300%"] }} transition={{ duration: 1.4, repeat: Infinity, ease: "easeInOut" }} /> )} </span> )} </header> {hasPreview && ( <TabsPrimitive.Content value="preview" className={cn( "min-h-0 flex-1 overflow-auto bg-[radial-gradient(var(--border)_1px,transparent_1px)] [background-size:16px_16px] p-5 outline-none focus-visible:ring-[3px] focus-visible:ring-inset focus-visible:ring-ring/50", previewClassName )} tabIndex={0} > {children} </TabsPrimitive.Content> )} {hasCode && ( <TabsPrimitive.Content value="code" className="min-h-0 flex-1 overflow-auto bg-muted/30 outline-none focus-visible:ring-[3px] focus-visible:ring-inset focus-visible:ring-ring/50" tabIndex={0} > {filename || language ? ( <div className="sticky top-0 flex h-8 items-center gap-2 border-b bg-muted/60 px-4 font-mono text-[11px] text-muted-foreground backdrop-blur"> {filename} {language && <span className="ms-auto uppercase">{language}</span>} </div> ) : null} <pre dir="ltr" className="p-4 font-mono text-xs leading-5 text-foreground"> <code>{code}</code> {streaming && ( <span aria-hidden="true" className="ms-0.5 inline-block h-3.5 w-1.5 translate-y-0.5 animate-pulse bg-foreground motion-reduce:animate-none" /> )} </pre> </TabsPrimitive.Content> )} </TabsPrimitive.Root> </section> ) } export { ArtifactPanel, type ArtifactPanelProps }Update the import paths to match your project setup.
Usage
import { ArtifactPanel } from "@/components/ballmac/artifact-panel"The full example is in the Code tab above.
Examples
Writing and versions
Release notes
Writing…
"use client"
import * as React from "react"
import { FileText } from "lucide-react"
import { ArtifactPanel } from "@/components/ballmac/artifact-panel"
const full = `# Release notes, v2.4
## Highlights
- Faster search across large workspaces
- Comments can now be resolved from email
- New keyboard shortcut palette (press ?)
## Fixes
- Pasting a table no longer drops the header row
- Dark mode contrast on code blocks`
export default function ArtifactPanelStreaming() {
const [text, setText] = React.useState("")
const [done, setDone] = React.useState(false)
React.useEffect(() => {
let i = 0
const id = setInterval(() => {
i += 6
setText(full.slice(0, i))
if (i >= full.length) {
clearInterval(id)
setDone(true)
}
}, 60)
return () => clearInterval(id)
}, [])
return (
<div className="h-[20rem] w-full max-w-xl">
<ArtifactPanel
className="h-full"
title="Release notes"
kind="Document"
icon={<FileText />}
code={text}
filename="release-notes.md"
language="md"
streaming={!done}
versions={done ? 2 : 1}
defaultTab="code"
>
<article className="mx-auto max-w-sm rounded-lg border bg-background p-5 text-sm leading-6 shadow-xs">
<h4 className="text-base font-semibold">Release notes, v2.4</h4>
<p className="mt-2 text-muted-foreground">Faster search, email-resolved comments and a shortcut palette.</p>
</article>
</ArtifactPanel>
</div>
)
}API reference
| Prop | Type | Default |
|---|---|---|
title*Name of the generated thing, such as "Pricing table". | string | — |
kindKind of artifact, shown under the title, such as "React component". | string | — |
iconIcon in the header tile. | React.ReactNode | — |
codeSource text. Shown in the Code tab and used for Copy and Download. | string | — |
filenameFile name for Download and the code header, such as "pricing-table.tsx". | string | — |
languageLanguage label for the code, such as "tsx". | string | — |
childrenThe rendered result. Put an iframe, a component or a document here. | React.ReactNode | — |
versionsHow many versions exist. With more than one, arrows to step through them appear. | number | 1 |
versionWhich version is shown, starting at 1 (controlled). | number | — |
onVersionChangeCalled when the version changes. | (version: number) => void | — |
streamingTrue while the model is still writing. Shows a progress edge and disables copy and download. | boolean | false |
defaultTabTab shown first. | "preview" | "code" | "preview" |
onCloseAdds a close button. | () => void | — |
onCopyCalled after the code was copied. | () => void | — |
actionsExtra header actions, placed before Copy. | React.ReactNode | — |
previewClassNameClasses for the preview canvas. | string | — |
Also accepts the standard attributes of its root element.
Accessibility
| Key | Action |
|---|---|
| ArrowLeft / ArrowRight | Switches between Preview and Code when the tabs have focus |
| Tab | Reaches version arrows, copy, download, close, and the scrollable content |
| Screen readers | Region named by the title; version changes are announced politely |
Use with AI
title, kind, icon, code, filename, language and children as the preview. versions > 1 adds steppers (version/onVersionChange). streaming shows a progress edge and disables copy and download. onClose adds a close button. With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI Artifact Panel (@ballmac/artifact-panel) to this project with the shadcn MCP, then use it where it fits.
Use it for
- Code, documents or designs a model produced next to the chat
- Anything users iterate on in versions
Not for
- Showing code in the chat; use code-block
- Generic tabs; use tabs
Registry JSON: https://ui.ballmac.com/r/artifact-panel.json
Credits
Free to use in personal and commercial projects.
- Registry
- @ballmac/i18nshadcn/utils
Pairs well with
AI Chat
The layout for a chat UI: a message log that sticks to the bottom while replies stream unless the reader scrolls up, a jump-to-latest button, an empty state with suggestions and a footer.
Code Block
A code panel with filename and language header, line numbers, highlighted lines, a wrap toggle, copy feedback and file tabs. No highlighter bundled; pass Shiki output as children.
Resizable
Draggable split panels with keyboard resizing, nested horizontal and vertical groups, collapsible panels, and a grip that appears on hover and focus.
Agent Plan
A live task plan for agents: a vertical timeline with pending, running, done, failed and skipped steps, nested substeps, expandable output, segmented progress and retry on failure.