AI Artifact
Rendered output area for AI-generated content with toolbar, copy/edit/download/fullscreen actions, and version chips.
Preview
Switch between light and dark to inspect the embedded Storybook preview.
Installation
pnpm dlx shadcn@latest add https://ui.vllnt.ai/r/ai-artifact.jsonbash
Storybook
Explore all variants, controls, and accessibility checks in the interactive Storybook playground.
View in StorybookCode
"use client";
import {
type ComponentPropsWithoutRef,
createContext,
forwardRef,
type ReactNode,
useCallback,
useContext,
useMemo,
useState,
} from "react";
import {
Check,
Copy,
Download,
Maximize2,
Minimize2,
Pencil,
} from "lucide-react";
import { cn } from "../../lib/utils";
import { Badge } from "../badge/badge";
import { Button } from "../button/button";
const COPIED_TIMEOUT_MS = 2000;
/**
* Artifact rendering type for {@link AIArtifact}. Drives the visual badge
* but consumers render the actual content inside {@link AIArtifactContent}.
*
* @public
*/
export type AIArtifactType =
| "code"
| "custom"
| "diagram"
| "document"
| "html"
| "image"
| "table";
/**
* Localizable strings.
*
* @public
*/
export type AIArtifactLabels = {
/** Aria-label after a successful copy. Defaults to `"Copied"`. */
copied?: string;
/** Aria-label for the copy control. Defaults to `"Copy"`. */
copy?: string;
/** Aria-label for the download control. Defaults to `"Download"`. */
download?: string;
/** Aria-label for the edit control. Defaults to `"Edit"`. */
edit?: string;
/** Aria-label for the fullscreen control when collapsed. Defaults to `"Enter fullscreen"`. */
enterFullscreen?: string;
/** Aria-label for the fullscreen control when expanded. Defaults to `"Exit fullscreen"`. */
exitFullscreen?: string;
/** Aria-label for the version list. Defaults to `"Versions"`. */
versions?: string;
};
const DEFAULT_LABELS = {
copied: "Copied",
copy: "Copy",
download: "Download",
edit: "Edit",
enterFullscreen: "Enter fullscreen",
exitFullscreen: "Exit fullscreen",
versions: "Versions",
} as const satisfies Required<AIArtifactLabels>;
type AIArtifactContextValue = {
copied: boolean;
copy: () => Promise<boolean>;
download: () => void;
filename: string;
fullscreen: boolean;
hasOnEdit: boolean;
labels: Required<AIArtifactLabels>;
onEdit: () => void;
toggleFullscreen: () => void;
type: AIArtifactType;
value: string;
};
const NO_OP = (): void => {
return;
};
const DEFAULT_CONTEXT: AIArtifactContextValue = {
copied: false,
copy: async () => false,
download: NO_OP,
filename: "artifact.txt",
fullscreen: false,
hasOnEdit: false,
labels: DEFAULT_LABELS,
onEdit: NO_OP,
toggleFullscreen: NO_OP,
type: "code",
value: "",
};
const AIArtifactContext =
createContext<AIArtifactContextValue>(DEFAULT_CONTEXT);
/**
* Hook for reading the artifact's state from inside a custom child.
*
* @public
*/
export function useAIArtifact(): AIArtifactContextValue {
return useContext(AIArtifactContext);
}
function pickExtension(type: AIArtifactType, language: string): string {
if (language) return language;
switch (type) {
case "code":
return "txt";
case "custom":
return "txt";
case "diagram":
return "mmd";
case "document":
return "md";
case "html":
return "html";
case "image":
return "png";
case "table":
return "csv";
}
}
const SLUG_INVALID_CHARS = /[^\da-z]+/g;
const SLUG_TRIM = /^-+|-+$/g;
type FilenameInput = {
filename?: string;
language: string;
title: ReactNode;
type: AIArtifactType;
};
function buildFilename({
filename,
language,
title,
type,
}: FilenameInput): string {
if (filename) return filename;
const base =
typeof title === "string" && title.length > 0 ? title : "artifact";
const slug = base
.toLowerCase()
.replaceAll(SLUG_INVALID_CHARS, "-")
.replaceAll(SLUG_TRIM, "");
const safeBase = slug.length > 0 ? slug : "artifact";
return `${safeBase}.${pickExtension(type, language)}`;
}
async function writeToClipboard(value: string): Promise<boolean> {
if (
typeof navigator === "undefined" ||
typeof navigator.clipboard?.writeText !== "function"
) {
return false;
}
try {
await navigator.clipboard.writeText(value);
return true;
} catch {
return false;
}
}
function downloadValueAsFile(value: string, filename: string): void {
if (typeof document === "undefined") return;
const blob = new Blob([value], { type: "text/plain;charset=utf-8" });
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = filename;
anchor.style.display = "none";
document.body.append(anchor);
anchor.click();
anchor.remove();
URL.revokeObjectURL(url);
}
/**
* Props for {@link AIArtifact}.
*
* @public
*/
export type AIArtifactProps = {
/** Initial fullscreen state. */
defaultFullscreen?: boolean;
/** Override the auto-derived filename for downloads. */
filename?: string;
/** Localizable strings. */
labels?: AIArtifactLabels;
/** Optional language tag rendered next to the title (e.g. `tsx`). */
language?: string;
/** Fires when the user clicks the edit control. */
onEdit?: () => void;
/** Subtitle / sub-headline. */
subtitle?: ReactNode;
/** Primary title. */
title?: ReactNode;
/** Artifact type — drives the badge and default download extension. */
type?: AIArtifactType;
/** Raw text content used by the copy + download controls. */
value?: string;
} & ComponentPropsWithoutRef<"section">;
type ArtifactHeaderProps = {
language: string;
subtitle?: ReactNode;
title?: ReactNode;
type: AIArtifactType;
};
function ArtifactHeader({
language,
subtitle,
title,
type,
}: ArtifactHeaderProps): ReactNode {
if (!title && !subtitle && !language) return null;
return (
<header className="flex flex-col gap-1">
<div className="flex flex-wrap items-center gap-2">
{title ? (
<h3 className="text-sm font-semibold tracking-tight text-foreground">
{title}
</h3>
) : null}
<Badge variant="secondary">{language || type}</Badge>
</div>
{subtitle ? (
<p className="text-xs text-muted-foreground">{subtitle}</p>
) : null}
</header>
);
}
/**
* Rendered output area for AI-generated content — code previews, documents,
* diagrams, or any custom artifact. Composes {@link Badge} + {@link Button}.
*
* The compound family pairs a root context with action buttons and a
* content slot:
*
* - {@link AIArtifactToolbar} — wraps the action row.
* - {@link AIArtifactCopyButton} / {@link AIArtifactDownloadButton} —
* wired to `value` + `filename` automatically.
* - {@link AIArtifactEditButton} — fires `onEdit`; hidden when no
* handler exists.
* - {@link AIArtifactFullscreenButton} — toggles `data-fullscreen` on the
* root so consumers can drive the layout via CSS.
* - {@link AIArtifactContent} — scrollable body slot for the actual
* payload (code block, MDX, mermaid output, iframe, etc.).
* - {@link AIArtifactVersions} / {@link AIArtifactVersion} — version
* navigator at the bottom.
*
* @example
* ```tsx
* <AIArtifact
* type="code"
* title="UserProfile.tsx"
* language="tsx"
* value={generatedCode}
* onEdit={openEditor}
* >
* <AIArtifactToolbar>
* <AIArtifactCopyButton />
* <AIArtifactEditButton />
* <AIArtifactDownloadButton />
* <AIArtifactFullscreenButton />
* </AIArtifactToolbar>
* <AIArtifactContent>
* <CodeBlock language="tsx">{generatedCode}</CodeBlock>
* </AIArtifactContent>
* </AIArtifact>
* ```
*
* @public
*/
type ControllerOptions = {
defaultFullscreen: boolean;
filename?: string;
labels: Required<AIArtifactLabels>;
language: string;
onEdit?: () => void;
title: ReactNode;
type: AIArtifactType;
value: string;
};
function useArtifactController(
options: ControllerOptions,
): AIArtifactContextValue {
const {
defaultFullscreen,
filename,
labels,
language,
onEdit,
title,
type,
value,
} = options;
const [fullscreen, setFullscreen] = useState(defaultFullscreen);
const [copied, setCopied] = useState(false);
const resolvedFilename = useMemo(
() => buildFilename({ filename, language, title, type }),
[filename, language, title, type],
);
const copy = useCallback(async () => {
const ok = await writeToClipboard(value);
if (!ok) return false;
setCopied(true);
setTimeout(() => {
setCopied(false);
}, COPIED_TIMEOUT_MS);
return true;
}, [value]);
const download = useCallback(() => {
downloadValueAsFile(value, resolvedFilename);
}, [resolvedFilename, value]);
const toggleFullscreen = useCallback(() => {
setFullscreen((current) => !current);
}, []);
const triggerEdit = useCallback(() => {
onEdit?.();
}, [onEdit]);
return useMemo<AIArtifactContextValue>(
() => ({
copied,
copy,
download,
filename: resolvedFilename,
fullscreen,
hasOnEdit: onEdit !== undefined,
labels,
onEdit: triggerEdit,
toggleFullscreen,
type,
value,
}),
[
copied,
copy,
download,
fullscreen,
labels,
onEdit,
resolvedFilename,
toggleFullscreen,
triggerEdit,
type,
value,
],
);
}
export const AIArtifact = forwardRef<HTMLElement, AIArtifactProps>(
(props, ref) => {
const {
children,
className,
defaultFullscreen = false,
filename,
labels,
language = "",
onEdit,
subtitle,
title,
type = "code",
value = "",
...rest
} = props;
const resolvedLabels = useMemo(
() => ({ ...DEFAULT_LABELS, ...labels }),
[labels],
);
const contextValue = useArtifactController({
defaultFullscreen,
filename,
labels: resolvedLabels,
language,
onEdit,
title,
type,
value,
});
return (
<AIArtifactContext.Provider value={contextValue}>
<section
aria-label={typeof title === "string" ? title : undefined}
className={cn(
"flex flex-col gap-3 rounded-2xl border bg-background p-4",
className,
)}
data-fullscreen={contextValue.fullscreen ? "true" : "false"}
data-type={type}
ref={ref}
{...rest}
>
<ArtifactHeader
language={language}
subtitle={subtitle}
title={title}
type={type}
/>
{children}
</section>
</AIArtifactContext.Provider>
);
},
);
AIArtifact.displayName = "AIArtifact";
/**
* Toolbar slot — wraps action buttons in a horizontal row.
*
* @public
*/
export const AIArtifactToolbar = forwardRef<
HTMLDivElement,
ComponentPropsWithoutRef<"div">
>(({ className, ...rest }, ref) => (
<div
className={cn(
"flex flex-wrap items-center gap-1.5 border-b border-border pb-2",
className,
)}
ref={ref}
role="toolbar"
{...rest}
/>
));
AIArtifactToolbar.displayName = "AIArtifactToolbar";
type ToolbarButtonProps = Omit<
ComponentPropsWithoutRef<"button">,
"children" | "type"
>;
/**
* Copy-to-clipboard control for the artifact's `value`. Visually flips to
* a check after a successful copy.
*
* @public
*/
export const AIArtifactCopyButton = forwardRef<
HTMLButtonElement,
ToolbarButtonProps
>(({ className, onClick, ...rest }, ref) => {
const { copied, copy, labels } = useAIArtifact();
const handleClick = useCallback(
(event: React.MouseEvent<HTMLButtonElement>) => {
onClick?.(event);
if (event.defaultPrevented) return;
void copy();
},
[copy, onClick],
);
return (
<Button
aria-label={copied ? labels.copied : labels.copy}
className={cn("size-8", className)}
onClick={handleClick}
ref={ref}
size="icon"
type="button"
variant="ghost"
{...rest}
>
{copied ? (
<Check aria-hidden="true" className="size-4" />
) : (
<Copy aria-hidden="true" className="size-4" />
)}
</Button>
);
});
AIArtifactCopyButton.displayName = "AIArtifactCopyButton";
/**
* Edit control. Renders nothing when the artifact has no `onEdit`
* handler so consumers do not have to conditionally hide it.
*
* @public
*/
export const AIArtifactEditButton = forwardRef<
HTMLButtonElement,
ToolbarButtonProps
>(({ className, onClick, ...rest }, ref) => {
const { hasOnEdit, labels, onEdit } = useAIArtifact();
const handleClick = useCallback(
(event: React.MouseEvent<HTMLButtonElement>) => {
onClick?.(event);
if (event.defaultPrevented) return;
onEdit();
},
[onClick, onEdit],
);
if (!hasOnEdit) return null;
return (
<Button
aria-label={labels.edit}
className={cn("size-8", className)}
onClick={handleClick}
ref={ref}
size="icon"
type="button"
variant="ghost"
{...rest}
>
<Pencil aria-hidden="true" className="size-4" />
</Button>
);
});
AIArtifactEditButton.displayName = "AIArtifactEditButton";
/**
* Download control — saves the artifact's `value` as a file with an
* auto-derived (or overridden) filename.
*
* @public
*/
export const AIArtifactDownloadButton = forwardRef<
HTMLButtonElement,
ToolbarButtonProps
>(({ className, onClick, ...rest }, ref) => {
const { download, labels } = useAIArtifact();
const handleClick = useCallback(
(event: React.MouseEvent<HTMLButtonElement>) => {
onClick?.(event);
if (event.defaultPrevented) return;
download();
},
[download, onClick],
);
return (
<Button
aria-label={labels.download}
className={cn("size-8", className)}
onClick={handleClick}
ref={ref}
size="icon"
type="button"
variant="ghost"
{...rest}
>
<Download aria-hidden="true" className="size-4" />
</Button>
);
});
AIArtifactDownloadButton.displayName = "AIArtifactDownloadButton";
/**
* Fullscreen toggle. Updates the root's `data-fullscreen` attribute so
* consumers can drive layout changes via CSS or React state on
* {@link useAIArtifact}.
*
* @public
*/
export const AIArtifactFullscreenButton = forwardRef<
HTMLButtonElement,
ToolbarButtonProps
>(({ className, onClick, ...rest }, ref) => {
const { fullscreen, labels, toggleFullscreen } = useAIArtifact();
const handleClick = useCallback(
(event: React.MouseEvent<HTMLButtonElement>) => {
onClick?.(event);
if (event.defaultPrevented) return;
toggleFullscreen();
},
[onClick, toggleFullscreen],
);
return (
<Button
aria-label={fullscreen ? labels.exitFullscreen : labels.enterFullscreen}
aria-pressed={fullscreen}
className={cn("size-8", className)}
onClick={handleClick}
ref={ref}
size="icon"
type="button"
variant="ghost"
{...rest}
>
{fullscreen ? (
<Minimize2 aria-hidden="true" className="size-4" />
) : (
<Maximize2 aria-hidden="true" className="size-4" />
)}
</Button>
);
});
AIArtifactFullscreenButton.displayName = "AIArtifactFullscreenButton";
/**
* Scrollable body slot. Render the actual payload here (code block,
* markdown, mermaid output, sandboxed iframe, etc.).
*
* @public
*/
export const AIArtifactContent = forwardRef<
HTMLDivElement,
ComponentPropsWithoutRef<"div">
>(({ className, ...rest }, ref) => (
<div
className={cn(
"min-h-[6rem] overflow-auto rounded-lg border border-border bg-muted/20 p-3 text-sm text-foreground",
className,
)}
ref={ref}
{...rest}
/>
));
AIArtifactContent.displayName = "AIArtifactContent";
/**
* Version navigator container.
*
* @public
*/
export const AIArtifactVersions = forwardRef<
HTMLElement,
ComponentPropsWithoutRef<"nav">
>(({ children, className, ...rest }, ref) => {
const { labels } = useAIArtifact();
return (
<nav
aria-label={labels.versions}
className={cn(
"flex flex-wrap items-center gap-1.5 border-t border-border pt-2",
className,
)}
ref={ref}
{...rest}
>
{children}
</nav>
);
});
AIArtifactVersions.displayName = "AIArtifactVersions";
/**
* Props for {@link AIArtifactVersion}.
*
* @public
*/
export type AIArtifactVersionProps = {
/** When true, renders with active styling and `aria-current="true"`. */
active?: boolean;
/** Caption for the chip. */
label: ReactNode;
} & Omit<ComponentPropsWithoutRef<"button">, "type">;
/**
* Single chip inside an {@link AIArtifactVersions}. Emits `onClick` so
* consumers can drive version state externally.
*
* @public
*/
export const AIArtifactVersion = forwardRef<
HTMLButtonElement,
AIArtifactVersionProps
>(({ active = false, className, label, ...rest }, ref) => (
<button
aria-current={active ? "true" : undefined}
className={cn(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
active
? "border-primary bg-primary text-primary-foreground"
: "border-border bg-background text-muted-foreground hover:bg-accent",
className,
)}
ref={ref}
type="button"
{...rest}
>
{label}
</button>
));
AIArtifactVersion.displayName = "AIArtifactVersion";
typescript