Scrim UI

Generative UI

Render a React component from a tool result instead of text — a skeleton in the widget's own shape, and a text fallback when the client has no renderer.

generative uitoolstreamingwidget
npx shadcn@latest add https://scrimui.dev/r/generative-ui.json
Agent promptClaude Code · Cursor · any agent
Add the Generative UI component from the Scrim UI registry to this project and use it in the configuration described below.

Generative UI — Render a React component from a tool result instead of text — a skeleton in the widget's own shape, and a text fallback when the client has no renderer.

## 1. Install

```bash
npx shadcn@latest add https://scrimui.dev/r/generative-ui.json
```

This writes a single file to `components/ui/generative-ui.tsx`. It is plain React + Tailwind with no runtime dependencies — no Radix, no CVA, nothing to add to package.json. If this project does not use the shadcn CLI, copy the source from https://scrimui.dev/components/generative-ui to the same path by hand; nothing in the file depends on shadcn.

## 2. Use it

```tsx
import { GenerativeUi } from "@/components/ui/generative-ui";

// Your own components — whatever the app already renders.
function WeatherCard() {
  return <div className="text-sm">29° Shenzhen — humid, feels like 34°</div>;
}

function WeatherSkeleton() {
  return <div className="h-12 animate-pulse rounded-xl bg-zinc-200" />;
}

<GenerativeUi
  tool="getWeather"
  state="streaming"
  skeleton={<WeatherSkeleton />}
  fallback={FALLBACK_TEXT}
  data={toolResultJson}
>
  <WeatherCard />
</GenerativeUi>
```

## 3. The configuration that matters

This is the component's "Streaming" state — The widget's own outline, not a spinner — the card never resizes when the data lands.

These props were set deliberately and should be preserved as written:

- `state` = "streaming" — State

Every other prop is at its default; leave those off the call site rather than writing the default value out.

Reference: https://scrimui.dev/components/generative-ui

Follows the props below — change a control and the prompt changes with it, so an agent reproduces that configuration instead of the defaults.

getWeathergenerating…

Presets

Props

Component source

Single-file React + Tailwind component. No dependencies — drop it into any project with Tailwind configured.

generative-ui.tsx
"use client";

import * as React from "react";

/* ------------------------------------------------------------------ */
/* Types                                                               */
/* ------------------------------------------------------------------ */

/**
 * `unsupported` is not an error state. A generative UI stream carries the
 * name of a widget the server decided to render, and a client is always one
 * deploy behind some of those names — a tool added this week, a lazy chunk
 * that failed, an older mobile build. The model still returned usable
 * content; the only thing missing is the renderer. Treating that as a failure
 * throws away an answer the user could have read.
 */
export type GenerativeState = "streaming" | "ready" | "unsupported";

export type GenerativeUiProps = {
  /** Tool the widget was rendered from. Shown as attribution. */
  tool: string;
  state?: GenerativeState;
  /** The widget. Rendered only once the tool result is complete. */
  children?: React.ReactNode;
  /**
   * Placeholder for `streaming`. Pass the widget's own shape rather than a
   * spinner — the layout is known before the data is, so there is no reason
   * to make the reader watch the card resize when it arrives.
   */
  skeleton?: React.ReactNode;
  /** Readable stand-in for `unsupported`. Prose, not an error code. */
  fallback?: React.ReactNode;
  /** Raw tool result behind the Data toggle. Omit to hide the toggle. */
  data?: string;
  defaultDataOpen?: boolean;
  className?: string;
};

/* ------------------------------------------------------------------ */
/* Icons                                                               */
/* ------------------------------------------------------------------ */

function SparkIcon(props: React.SVGProps<SVGSVGElement>) {
  return (
    <svg viewBox="0 0 24 24" fill="currentColor" width="12" height="12" {...props}>
      <path d="M12 2.5 13.7 8 19 9.7 13.7 11.4 12 16.9 10.3 11.4 5 9.7 10.3 8ZM18.5 15l.8 2.4 2.4.8-2.4.8-.8 2.4-.8-2.4-2.4-.8 2.4-.8Z" />
    </svg>
  );
}

function ChevronIcon(props: React.SVGProps<SVGSVGElement>) {
  return (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" width="12" height="12" {...props}>
      <path d="m6 9 6 6 6-6" />
    </svg>
  );
}

function TextIcon(props: React.SVGProps<SVGSVGElement>) {
  return (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" width="14" height="14" {...props}>
      <path d="M4 6h16M4 12h16M4 18h10" />
    </svg>
  );
}

/* ------------------------------------------------------------------ */
/* Default skeleton                                                    */
/* ------------------------------------------------------------------ */

/**
 * Only a fallback for the `skeleton` prop. Generic bars are the thing this
 * component exists to avoid, so a real integration should pass the widget's
 * own outline instead.
 */
function DefaultSkeleton() {
  return (
    <div className="animate-pulse space-y-2.5">
      <div className="h-3 w-1/3 rounded-full bg-zinc-200 dark:bg-zinc-800" />
      <div className="h-8 w-2/3 rounded-lg bg-zinc-200 dark:bg-zinc-800" />
      <div className="h-3 w-1/2 rounded-full bg-zinc-200 dark:bg-zinc-800" />
    </div>
  );
}

/* ------------------------------------------------------------------ */
/* GenerativeUi                                                        */
/* ------------------------------------------------------------------ */

export function GenerativeUi({
  tool,
  state = "ready",
  children,
  skeleton,
  fallback,
  data,
  defaultDataOpen = false,
  className = "",
}: GenerativeUiProps) {
  const [dataOpen, setDataOpen] = React.useState(defaultDataOpen);
  const hasData = (data?.length ?? 0) > 0;

  return (
    <div
      className={`overflow-hidden rounded-xl border bg-white transition-colors dark:bg-zinc-900 ${
        state === "unsupported"
          ? "border-amber-200 dark:border-amber-900/60"
          : "border-zinc-200 dark:border-zinc-800"
      } ${className}`}
    >
      {/* The widget leads. A tool call is a process the reader is waiting on,
          so that component puts its status bar on top; this one is a result
          they are reading, and pushing it below a status bar would make the
          chrome look like the content. */}
      <div className="px-3.5 py-3">
        {state === "streaming" && (
          /* aria-busy rather than a live region: the skeleton is decorative,
             and announcing every bar as it settles is noise. The completed
             widget is what should be read out, and it announces itself. */
          <div aria-busy="true">{skeleton ?? <DefaultSkeleton />}</div>
        )}

        {state === "ready" && children}

        {state === "unsupported" && (
          <div className="flex gap-2.5">
            <span className="mt-px shrink-0 text-amber-600 dark:text-amber-500">
              <TextIcon />
            </span>
            <div className="min-w-0 text-sm leading-6 text-zinc-700 dark:text-zinc-300">
              {fallback ?? `This app cannot display the ${tool} result yet.`}
            </div>
          </div>
        )}
      </div>

      {/* Attribution.

          Generative UI puts model output in the same visual language as the
          app's own interface, which is exactly what makes it worth building
          and exactly what makes it worth labelling. The footer is the answer
          to "did a person build this card or did a model fill it in?" */}
      <div className="flex items-center gap-2 border-t border-zinc-100 bg-zinc-50/60 px-3.5 py-2 dark:border-zinc-800 dark:bg-zinc-800/30">
        <span className="shrink-0 text-zinc-400 dark:text-zinc-500">
          <SparkIcon />
        </span>
        <span className="min-w-0 truncate font-mono text-[11px] text-zinc-500 dark:text-zinc-400">
          {tool}
        </span>
        {state === "streaming" && (
          <span className="shrink-0 text-[11px] text-zinc-400 dark:text-zinc-500">generating…</span>
        )}
        {state === "unsupported" && (
          <span className="shrink-0 text-[11px] text-amber-600 dark:text-amber-500">
            no renderer
          </span>
        )}
        {hasData && (
          <button
            type="button"
            onClick={() => setDataOpen((v) => !v)}
            aria-expanded={dataOpen}
            /* min-h-6 keeps the tap target at the 24px minimum; at this text
               size the padding alone lands around 20px. */
            className="ml-auto inline-flex min-h-6 shrink-0 items-center gap-1 rounded-md px-1.5 text-[11px] text-zinc-500 transition-colors hover:bg-zinc-200/60 hover:text-zinc-700 dark:text-zinc-400 dark:hover:bg-zinc-700/60 dark:hover:text-zinc-200"
          >
            Data
            <ChevronIcon className={dataOpen ? "rotate-180" : ""} />
          </button>
        )}
      </div>

      {hasData && dataOpen && (
        <pre className="max-h-56 overflow-auto border-t border-zinc-100 bg-zinc-50 p-3 font-mono text-xs leading-5 text-zinc-700 dark:border-zinc-800 dark:bg-zinc-800/60 dark:text-zinc-300">
          {data}
        </pre>
      )}
    </div>
  );
}

When to use it

  • Give every widget a skeleton in its own shape. The layout is known before the data is, so there is no excuse for the card resizing when the result lands.
  • Always ship a text fallback. Clients run older builds than servers, and a widget the client cannot render should still leave a readable answer behind.
  • Label the widget. Once model output looks exactly like your own UI, the attribution line is the only thing telling the user which one they are reading.
  • Keep the raw tool result one click away. Generated cards get questioned, and 'where did 78% come from' should be answerable without opening devtools.
  • Render widgets from tool results you trust. A component driven by model output is a component driven by whatever the tool returned.

What breaks in production

  • A generic spinner instead of the widget's outline — the card pops into place at a different height and the message list jumps.
  • Treating an unknown widget type as an error. The model answered; only the renderer is missing. Show the text.
  • Letting a generated card look identical to a card the user filled in themselves. Interactive widgets especially need to say where their numbers came from.
  • Streaming partial data straight into the live widget. Half-arrived props render as 'undefined°' and read as a bug rather than as progress.
  • Putting the status bar on top and the widget below it, copying the tool call layout. Here the result is the content, not the process.

Related Components