Skip to content
Seamless UI

Artifact Preview

Save

A file an agent produced: name, type, size, status, an inline preview, and where to open it.

Category
Agent
Version
0.2.0
Frameworks
Next.js, Vite + React
Styling
Tailwind CSS v4

Preview

Artifact Preview preview

rate-limits.csv

text/csv · 58 B

Ready
plan,requests_per_minute
free,60
pro,600

Installation

npx @seamlessui/cli add artifact-preview

Pre-release@seamlessui/cli is not on npm yet. Install from a local build.

Review the plan first. A dry run lists every file and package without changing anything:

npx @seamlessui/cli add artifact-preview --dry-run

Usage

artifact-preview-example.tsx
import { ArtifactPreview } from "@/components/seamless/agent/artifact-preview";

export default function ArtifactPreviewExample() {
  return (
    <ArtifactPreview
      className="w-full max-w-md"
      name="rate-limits.csv"
      type="text/csv"
      status="ready"
      path="/artifacts/rate-limits.csv"
      sizeBytes={58}
      content={"plan,requests_per_minute\nfree,60\npro,600"}
    />
  );
}

States

Every state, rendered from local fixture data.

Artifact Preview states

report.md

text/markdown

Generating

Writing the file

report.md

text/markdown · 30 B

Ready
# Report
All checks passed.

report.md

text/markdown

Failed

The source page timed out.

API

PropTypeDefaultDescription
name / typestring—File name and MIME type.
status"generating" | "ready" | "failed"—Generating shows a static placeholder and a spinner; failed shows the error.
pathstring—Where to open or download the file. Images preview from this path.
contentstring—Text to preview for text and JSON types, rendered as text.
sizeBytes / errornumber / string—Optional size and failure message.

Accessibility

  • Status is a text badge in a live region.
  • The download link names the file for screen readers.
  • The generating placeholder is decorative and static; only its small spinner turns, and it holds still with reduced motion.

Files

Installed into src/components/seamless, or components/seamless when the project has no src directory.

  • src/components/seamless/agent/artifact-preview.tsxartifact-preview
  • Also installs badge@0.2.0registry dependency
  • Also installs agent-run@0.1.1registry dependency
  • npm packages: noneReact and Tailwind CSS v4 required

Source

The exact source the CLI installs, from /r/items/react/artifact-preview/0.2.0.json.

agent/artifact-preview.tsxShow
agent/artifact-preview.tsx
import type { ComponentProps } from "react";
import { Badge, type BadgeTone } from "../ui/badge";
import type { ArtifactStatus } from "./agent-run";

const statuses: Record<ArtifactStatus, { label: string; tone: BadgeTone }> = {
  generating: { label: "Generating", tone: "info" },
  ready: { label: "Ready", tone: "success" },
  failed: { label: "Failed", tone: "danger" },
};

const enter =
  "transition-[opacity,translate] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] starting:translate-y-1 starting:opacity-0 motion-reduce:transition-none";

export interface ArtifactPreviewProps extends Omit<ComponentProps<"article">, "children"> {
  name: string;
  /** MIME type, e.g. text/markdown, text/csv, application/json, image/png. */
  type: string;
  status: ArtifactStatus;
  /** Where to open or download the artifact once it is ready. */
  path?: string;
  /** Text content to preview for text-like types. Rendered as text, never HTML. */
  content?: string;
  sizeBytes?: number;
  error?: string;
}

/** A file an agent produced: name, type, size, status, a preview, and where to open it. */
export function ArtifactPreview({ name, type, status, path, content, sizeBytes, error, className, ...props }: ArtifactPreviewProps) {
  const meta = statuses[status];
  const isImage = type.startsWith("image/");
  const isText = type.startsWith("text/") || type === "application/json";

  return (
    <article
      data-status={status}
      aria-busy={status === "generating" || undefined}
      className={[
        "overflow-hidden rounded-xl border bg-white text-zinc-950 shadow-[0_1px_2px_rgb(0_0_0/0.04)] transition-[border-color] duration-200 motion-reduce:transition-none",
        "dark:bg-black dark:text-white dark:shadow-[inset_0_1px_0_rgb(255_255_255/0.06)]",
        "border-zinc-950/10 dark:border-white/10",
        className,
      ]
        .filter(Boolean)
        .join(" ")}
      {...props}
    >
      <header className="flex items-center gap-3 px-4 py-3">
        <FileIcon extension={extensionOf(name)} />
        <div className="min-w-0 flex-1">
          <h3 className="truncate text-sm font-medium">{name}</h3>
          <p className="truncate font-mono text-xs text-zinc-500">
            {type}
            {sizeBytes != null ? ` · ${formatSize(sizeBytes)}` : ""}
          </p>
        </div>
        <span role="status" className="shrink-0">
          <Badge tone={meta.tone}>{meta.label}</Badge>
        </span>
      </header>

      <div key={status} className={`border-t border-zinc-950/[0.07] p-4 dark:border-white/[0.08] ${enter}`}>
        {status === "generating" ? (
          <div className="grid gap-3">
            <div aria-hidden="true" className="grid gap-2">
              {["w-11/12", "w-4/5", "w-3/5"].map((width) => (
                <div key={width} className={`h-2 rounded-full bg-zinc-950/[0.06] dark:bg-white/[0.08] ${width}`} />
              ))}
            </div>
            <p className="flex items-center gap-2 text-xs text-zinc-500">
              <Spinner />
              Writing the file
            </p>
          </div>
        ) : status === "failed" ? (
          <p className="flex items-start gap-2 text-sm text-zinc-700 dark:text-zinc-300">
            <svg aria-hidden="true" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" className="mt-0.5 size-4 shrink-0 text-red-600 dark:text-red-400">
              <circle cx="8" cy="8" r="6.25" />
              <path d="m6.1 6.1 3.8 3.8m0-3.8-3.8 3.8" />
            </svg>
            <span>{error ?? "The artifact could not be generated."}</span>
          </p>
        ) : isImage && path ? (
          <img
            src={path}
            alt={`Preview of ${name}`}
            className="max-h-64 rounded-lg border border-zinc-950/[0.07] object-contain dark:border-white/[0.08]"
          />
        ) : isText && content != null ? (
          <pre
            className={`max-h-64 overflow-auto rounded-lg ${type === "text/markdown" || type === "text/plain" ? "break-words whitespace-pre-wrap" : ""} border border-zinc-950/[0.07] bg-zinc-950/[0.02] px-3 py-2.5 font-mono text-xs leading-relaxed text-zinc-800 dark:border-white/[0.08] dark:bg-transparent dark:text-zinc-300`}
          >
            {content}
          </pre>
        ) : (
          <p className="text-sm text-zinc-500">No preview for this file type.</p>
        )}
      </div>

      {status === "ready" && path ? (
        <footer className="flex items-center justify-between gap-3 border-t border-zinc-950/[0.07] px-4 py-2.5 dark:border-white/[0.08]">
          <code className="min-w-0 truncate font-mono text-xs text-zinc-500">{path}</code>
          <a
            href={path}
            download={name}
            className={[
              "inline-flex h-7 shrink-0 items-center gap-1.5 rounded-lg border border-zinc-950/12 px-2.5 text-xs font-medium text-zinc-800",
              "transition-colors duration-150 hover:border-zinc-950/20 hover:bg-zinc-950/[0.03] motion-reduce:transition-none",
              "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-500",
              "dark:border-white/15 dark:text-zinc-200 dark:hover:border-white/25 dark:hover:bg-white/[0.06]",
            ].join(" ")}
          >
            <svg aria-hidden="true" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" className="size-3.5">
              <path d="M8 2.75v7.5M4.75 7 8 10.25 11.25 7M3 13.25h10" />
            </svg>
            Download<span className="sr-only"> {name}</span>
          </a>
        </footer>
      ) : null}
    </article>
  );
}

/** A file tile: the extension in mono when there is one, otherwise a page outline. */
function FileIcon({ extension }: { extension: string }) {
  return (
    <span
      aria-hidden="true"
      className="grid size-9 shrink-0 place-items-center rounded-lg border border-zinc-950/10 font-mono text-[11px] font-medium text-zinc-600 dark:border-white/10 dark:text-zinc-400"
    >
      {extension || (
        <svg viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.25" strokeLinejoin="round" className="size-5">
          <path d="M5.5 2.75h6l3 3v11.5h-9z" />
          <path d="M11.5 2.75v3h3" />
        </svg>
      )}
    </span>
  );
}

function Spinner() {
  return (
    <svg aria-hidden="true" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" className="size-3.5 shrink-0 text-sky-600 dark:text-sky-400">
      <circle cx="8" cy="8" r="6.25" strokeOpacity="0.2" />
      <path d="M8 1.75A6.25 6.25 0 1 1 1.75 8" className="origin-center motion-safe:animate-spin" />
    </svg>
  );
}

function extensionOf(name: string): string {
  const match = name.match(/\.([a-z0-9]{1,4})$/i);
  return match ? match[1]!.toLowerCase() : "";
}

function formatSize(bytes: number): string {
  if (bytes < 1024) return `${bytes} B`;
  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
  return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}