Skip to main content

ns-ui

Skeleton Schema

Schema-driven skeleton for streaming structured LLM output: every field, key, and array slot renders as a dashed empty mold the instant the shape is known, then sets solid field-by-field as values pour in.

Usage

registry/core/skeleton-schema/demo.tsx
"use client";

import { useEffect, useState } from "react";
import { SlipCast, type JsonRecord, type JsonValue, type SlotSchema } from "./component";

// A structured-extraction tool call: a flat header, a nested "flags" object,
// and a "legs" array of objects — the three shapes the component has to
// prove out (scalar slot, nested-object container, growing array with a
// ghost buffer). `SCHEMA` is known in full up front, exactly as it would be
// the moment a model's tool-call schema resolves, before any argument value
// exists.

const SCHEMA: SlotSchema[] = [
  { key: "origin", kind: "string" },
  { key: "destination", kind: "string" },
  { key: "travelers", kind: "number" },
  {
    key: "flags",
    kind: "object",
    fields: [
      { key: "nonstopOnly", kind: "boolean" },
      { key: "cabin", kind: "string" },
      { key: "loyaltyProgram", kind: "null" },
    ],
  },
  {
    key: "legs",
    kind: "array",
    item: [
      { key: "carrier", kind: "string" },
      { key: "price", kind: "number" },
      { key: "durationMin", kind: "number" },
    ],
  },
];

// Ordered arrival of dotted paths, the way keys land as a model streams
// tool-call arguments token by token. Setting "legs.0.carrier" before
// "legs.0.price" creates the array entry on first touch.
const STREAM: Array<{ path: string; value: JsonValue }> = [
  { path: "origin", value: "SEA" },
  { path: "destination", value: "NRT" },
  { path: "travelers", value: 2 },
  { path: "flags.nonstopOnly", value: true },
  { path: "flags.cabin", value: "premium-economy" },
  { path: "flags.loyaltyProgram", value: null },
  { path: "legs.0.carrier", value: "ANA" },
  { path: "legs.0.durationMin", value: 583 },
  { path: "legs.0.price", value: 742 },
  { path: "legs.1.carrier", value: "Delta" },
  { path: "legs.1.price", value: 811 },
  { path: "legs.1.durationMin", value: 611 },
  { path: "legs.2.carrier", value: "JAL" },
  { path: "legs.2.price", value: 693 },
  { path: "legs.2.durationMin", value: 597 },
];

const STEP_MS = 480;
const HOLD_MS = 2600;

function setAtPath(root: JsonRecord, path: string, value: JsonValue): JsonRecord {
  const parts = path.split(".");
  const next: JsonRecord = { ...root };
  let cursor: Record<string, JsonValue> = next;
  parts.forEach((part, i) => {
    const isLast = i === parts.length - 1;
    if (isLast) {
      cursor[part] = value;
      return;
    }
    const isArrayKey = /^\d+$/.test(parts[i + 1]);
    const existing = cursor[part];
    const container: JsonValue = isArrayKey
      ? Array.isArray(existing)
        ? [...existing]
        : []
      : existing && typeof existing === "object" && !Array.isArray(existing)
        ? { ...(existing as JsonRecord) }
        : {};
    cursor[part] = container;
    cursor = container as Record<string, JsonValue>;
  });
  return next;
}

export default function SlipCastDemo() {
  const [cycle, setCycle] = useState(0);
  const [step, setStep] = useState(0);

  // remounts the whole stream on a loop so the preview / autoplay card is
  // always mid-pour rather than settling on a finished, static frame
  useEffect(() => {
    setStep(0);
    const totalMs = STREAM.length * STEP_MS + HOLD_MS;
    const tick = setInterval(() => {
      setStep((s) => (s < STREAM.length ? s + 1 : s));
    }, STEP_MS);
    const restart = setTimeout(() => setCycle((c) => c + 1), totalMs);
    return () => {
      clearInterval(tick);
      clearTimeout(restart);
    };
  }, [cycle]);

  let value: JsonRecord = {};
  for (let i = 0; i < step; i++) {
    value = setAtPath(value, STREAM[i].path, STREAM[i].value);
  }
  const streaming = step < STREAM.length;

  return (
    <div className="flex min-h-screen items-center justify-center px-6 py-16">
      <div className="w-full max-w-md rounded-md border border-border bg-background p-5">
        <SlipCast key={cycle} schema={SCHEMA} value={value} streaming={streaming} label="search_flights arguments" />
      </div>
    </div>
  );
}

Component

registry/core/skeleton-schema/component.tsx
"use client";

import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";

// SlipCast — pour streaming, schema-shaped JSON into a visible mold. The
// moment the schema is known (before a single token of data has arrived) the
// full shape of the answer renders as a skeleton of empty slots: every key
// already typeset in Geist Mono at --ns-muted, sitting in a dashed 1px --border
// blank pre-sized by its declared type (numbers short, booleans a fixed
// true/false width, strings full-row so a long value never reflows the row
// that holds it). That skeleton IS the progress bar — there is no separate
// spinner or bar element anywhere in this component.
//
// As each value streams in, its slot — and only its slot — flips from
// pending to set: the dashed border crossfades to solid over 150ms
// ease-out-expo (border-style itself can't be animated, so this is two
// stacked frames whose opacity crosses over, not a literal style
// interpolation), the value drops in with a small spring overshoot
// (translateY 3px -> 0), and a 4px corner tick fades --ns-muted -> --foreground.
// Nested objects get the same dashed->solid treatment on their own
// container, independent of whether their children have filled yet — a
// nested object "sets" the instant its key exists in the stream, even with
// every field inside it still dashed. Arrays keep two ghost rows appended
// past the last arrived item at all times, so the row a value is about to
// land in was already on screen, dashed, before it filled — nothing new
// reflows into existence under the incoming value itself, only the trailing
// ghost buffer grows. Nesting indents in fixed 16px steps.
//
// Content that mounts already-complete (a value with every field present on
// first render) skips the fill animation entirely — the "live" transitions
// only arm after mount, so a component that never streamed doesn't play a
// pointless cascade.
//
// Unlike a generic content-shaped loading skeleton (bars standing in for
// unknown prose), this is schema-driven and field-granular: real keys are
// typeset before any value exists, every slot tracks its own pending/set
// state independently, and a slot still dashed when streaming ends is left
// exactly as dashed — an honest "this field never arrived" rather than a
// skeleton dissolving into empty space. It renders the *shape* of a
// streaming payload; it does not own the character-level caret inside a
// value that is itself still arriving — that's streaming-markdown-caret's job, and the two
// compose (a string slot's value can itself be a streaming-markdown-caret span).
//
// Pure DOM + CSS: real dt/dd pairs inside real dl elements (nested objects
// get a nested dl, array items a dl per row), role=region with aria-busy
// while streaming, a visually-hidden aria-live=polite region that announces
// batched "N of M fields complete" milestones (debounced, never per-token),
// and exactly one focusable control per top-level object: a Copy JSON
// button. Colors are tokens only (--background --foreground --ns-muted
// --border --ns-accent); --ns-accent appears only on the copy button's focus
// ring. prefers-reduced-motion drops every transition — slots simply appear
// filled, nothing eases.

export type SlotType = "string" | "number" | "boolean" | "null";
export type SlotKind = SlotType | "object" | "array";

export interface SlotSchema {
  /** the real field name, typeset verbatim as the dt */
  key: string;
  kind: SlotKind;
  /** children, for kind: "object" */
  fields?: SlotSchema[];
  /** shape of one array item, for kind: "array" — an object shape, or a scalar kind for a flat array */
  item?: SlotSchema[] | SlotType;
}

export type JsonValue =
  | string
  | number
  | boolean
  | null
  | JsonValue[]
  | { [key: string]: JsonValue }
  | undefined;

export type JsonRecord = Record<string, JsonValue>;

export interface SlipCastProps {
  /** the known shape of the answer, available before any value has arrived */
  schema: SlotSchema[];
  /** the accumulated value so far — a deep-partial object; absent keys read as pending */
  value: JsonRecord | undefined;
  /** true while more values are still expected; drives aria-busy and the milestone copy */
  streaming?: boolean;
  /** accessible name for the region, and the noun the copy button acts on */
  label?: string;
  /** extra classes merged onto the rendered root element */
  className?: string;
}

const GHOST_ROWS_AHEAD = 2;
const ANNOUNCE_DEBOUNCE_MS = 350;

function hasOwn(obj: unknown, key: string): obj is Record<string, JsonValue> {
  return typeof obj === "object" && obj !== null && Object.prototype.hasOwnProperty.call(obj, key);
}

function getAtPath(root: JsonValue, path: string): { present: boolean; value: JsonValue } {
  let cur: JsonValue = root;
  for (const part of path.split(".")) {
    if (!hasOwn(cur, part)) return { present: false, value: undefined };
    cur = (cur as JsonRecord)[part];
    if (cur === undefined) return { present: false, value: undefined };
  }
  return { present: true, value: cur };
}

/** Flattens the schema into the fixed set of slots the milestone count is
 * measured against. Arrays count as exactly one slot — their item rows are
 * unbounded and streaming, so they're excluded from the denominator or "4 of
 * 9" would keep retargeting mid-stream. */
function flattenSchema(fields: SlotSchema[], base = ""): string[] {
  const out: string[] = [];
  for (const f of fields) {
    const path = base ? `${base}.${f.key}` : f.key;
    out.push(path);
    if (f.kind === "object" && f.fields) out.push(...flattenSchema(f.fields, path));
  }
  return out;
}

function formatScalar(kind: SlotType, value: JsonValue): ReactNode {
  if (kind === "null") return <span className="italic">null</span>;
  if (kind === "boolean") return value ? "true" : "false";
  if (kind === "number") return typeof value === "number" ? value.toLocaleString() : String(value ?? "");
  return typeof value === "string" ? value : String(value ?? "");
}

function ValueSlot({ kind, present, value }: { kind: SlotType; present: boolean; value: JsonValue }) {
  return (
    <span className={`ns-slip-value-wrap ns-slip-${kind}`} data-state={present ? "set" : "pending"}>
      <span className="ns-slip-edge ns-slip-edge-dashed" aria-hidden="true" />
      <span className="ns-slip-edge ns-slip-edge-solid" aria-hidden="true" />
      <span className="ns-slip-tick" aria-hidden="true" />
      <span className="ns-slip-value">{present ? formatScalar(kind, value) : ""}</span>
    </span>
  );
}

function ObjectSlot({ present, children }: { present: boolean; children: ReactNode }) {
  // a div, not a span: a <dl> is flow content and cannot legally nest inside
  // phrasing content — a span wrapper here would get silently reparented by
  // the HTML parser, taking the edge-overlay positioning context with it
  return (
    <div className="ns-slip-container" data-state={present ? "set" : "pending"}>
      <span className="ns-slip-edge ns-slip-edge-dashed" aria-hidden="true" />
      <span className="ns-slip-edge ns-slip-edge-solid" aria-hidden="true" />
      <span className="ns-slip-tick" aria-hidden="true" />
      <dl className="ns-slip-dl">{children}</dl>
    </div>
  );
}

function renderField(field: SlotSchema, parent: JsonValue, depth: number, keyPrefix: string): ReactNode {
  const present = hasOwn(parent, field.key);
  const raw = present ? (parent as JsonRecord)[field.key] : undefined;
  const rowKey = `${keyPrefix}.${field.key}`;
  const indent = { paddingLeft: depth * 16 };

  if (field.kind === "object") {
    const nested = field.fields ?? [];
    return (
      <div className="ns-slip-row" style={indent} key={rowKey}>
        <dt className="ns-slip-key">{field.key}</dt>
        <dd>
          <ObjectSlot present={present}>
            {nested.map((child) => renderField(child, raw, depth + 1, rowKey))}
          </ObjectSlot>
        </dd>
      </div>
    );
  }

  if (field.kind === "array") {
    const arr = present && Array.isArray(raw) ? raw : [];
    const rowCount = arr.length + GHOST_ROWS_AHEAD;
    const itemFields = Array.isArray(field.item) ? field.item : undefined;
    const scalarKind: SlotType = typeof field.item === "string" ? field.item : "string";
    return (
      <div className="ns-slip-row" style={indent} key={rowKey}>
        <dt className="ns-slip-key">{field.key}</dt>
        <dd>
          <ObjectSlot present={present}>
            {Array.from({ length: rowCount }, (_, i) => {
              const itemPresent = i < arr.length;
              const itemValue = itemPresent ? arr[i] : undefined;
              const itemRowKey = `${rowKey}.${i}`;
              if (itemFields) {
                return (
                  <div className="ns-slip-row" style={{ paddingLeft: (depth + 1) * 16 }} key={itemRowKey}>
                    <dt className="ns-slip-index">[{i}]</dt>
                    <dd>
                      <ObjectSlot present={itemPresent}>
                        {itemFields.map((child) => renderField(child, itemValue, depth + 2, itemRowKey))}
                      </ObjectSlot>
                    </dd>
                  </div>
                );
              }
              return (
                <div className="ns-slip-row" style={{ paddingLeft: (depth + 1) * 16 }} key={itemRowKey}>
                  <dt className="ns-slip-index">[{i}]</dt>
                  <dd>
                    <ValueSlot kind={scalarKind} present={itemPresent} value={itemValue} />
                  </dd>
                </div>
              );
            })}
          </ObjectSlot>
        </dd>
      </div>
    );
  }

  return (
    <div className="ns-slip-row" style={indent} key={rowKey}>
      <dt className="ns-slip-key">{field.key}</dt>
      <dd>
        <ValueSlot kind={field.kind} present={present} value={raw} />
      </dd>
    </div>
  );
}

async function copyToClipboard(text: string): Promise<boolean> {
  try {
    if (navigator.clipboard?.writeText) {
      await navigator.clipboard.writeText(text);
      return true;
    }
  } catch {
    // fall through to the legacy path below
  }
  try {
    const ta = document.createElement("textarea");
    ta.value = text;
    ta.style.position = "fixed";
    ta.style.opacity = "0";
    document.body.appendChild(ta);
    ta.focus();
    ta.select();
    const ok = document.execCommand("copy");
    document.body.removeChild(ta);
    return ok;
  } catch {
    return false;
  }
}

const CSS = `
.ns-skeleton-schema dt,.ns-skeleton-schema dd,.ns-skeleton-schema dl{margin:0;padding:0}
.ns-skeleton-schema .ns-slip-dl{display:flex;flex-direction:column;gap:8px}
.ns-skeleton-schema .ns-slip-row{display:flex;align-items:flex-start;gap:12px}
.ns-skeleton-schema .ns-slip-row > dd{flex:1;min-width:0;display:flex}
.ns-skeleton-schema .ns-slip-key,.ns-skeleton-schema .ns-slip-index{
  flex:0 0 auto;min-width:64px;padding-top:3px;
  font-family:var(--font-mono);font-size:11.5px;line-height:18px;color:var(--ns-muted);white-space:nowrap;
}
.ns-skeleton-schema .ns-slip-index{opacity:.75;min-width:32px}

.ns-skeleton-schema .ns-slip-container{position:relative;display:block;flex:1;padding:8px;border-radius:12px}
.ns-skeleton-schema .ns-slip-container > .ns-slip-dl{position:relative}

.ns-skeleton-schema .ns-slip-value-wrap{
  position:relative;display:inline-flex;align-items:center;box-sizing:border-box;
  min-height:22px;padding:2px 7px;border-radius:6px;
}
.ns-skeleton-schema .ns-slip-string{display:flex;width:100%}
.ns-skeleton-schema .ns-slip-number{min-width:3.5ch;justify-content:flex-end;font-family:var(--font-mono);font-variant-numeric:tabular-nums}
.ns-skeleton-schema .ns-slip-boolean{width:5ch;font-family:var(--font-mono)}
.ns-skeleton-schema .ns-slip-null{width:4ch;font-family:var(--font-mono)}

.ns-skeleton-schema .ns-slip-edge{position:absolute;inset:0;border-radius:inherit;pointer-events:none}
.ns-skeleton-schema .ns-slip-edge-dashed{border:1px dashed var(--border);opacity:1}
.ns-skeleton-schema .ns-slip-edge-solid{border:1px solid var(--border);opacity:0}
.ns-skeleton-schema [data-state="set"] > .ns-slip-edge-dashed{opacity:0}
.ns-skeleton-schema [data-state="set"] > .ns-slip-edge-solid{opacity:1}
.ns-skeleton-schema [data-state="set"] > .ns-slip-tick{background:var(--foreground)}
.ns-skeleton-schema [data-state="set"] > .ns-slip-value{opacity:1;transform:translateY(0)}

.ns-skeleton-schema .ns-slip-tick{
  position:absolute;top:-1px;left:-1px;width:4px;height:4px;background:var(--ns-muted);
}
.ns-skeleton-schema .ns-slip-value{position:relative;font-size:12.5px;color:var(--foreground);opacity:0;transform:translateY(3px)}

/* the fill transitions only arm once mounted-live, so pre-filled content
   (a value that arrived complete on first render) never plays a cascade it
   didn't earn */
.ns-skeleton-schema.ns-slip-live .ns-slip-edge{transition:opacity 150ms cubic-bezier(.16,1,.3,1)}
.ns-skeleton-schema.ns-slip-live .ns-slip-tick{transition:background-color 200ms ease-out}
.ns-skeleton-schema.ns-slip-live .ns-slip-value{transition:transform 320ms cubic-bezier(.34,1.56,.64,1),opacity 200ms ease-out}

.ns-skeleton-schema .ns-slip-copy{outline:none}
.ns-skeleton-schema .ns-slip-copy:focus-visible{outline:2px solid var(--ns-accent);outline-offset:2px}

@media (prefers-reduced-motion: reduce){
  .ns-skeleton-schema .ns-slip-edge,.ns-skeleton-schema .ns-slip-tick,.ns-skeleton-schema .ns-slip-value{transition:none !important}
}
`;

export function SlipCast({ schema, value, streaming = false, label = "Structured output", className = "" }: SlipCastProps) {
  const [live, setLive] = useState(false);
  const [copied, setCopied] = useState(false);
  const [announced, setAnnounced] = useState("");
  const copyTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
  const announceTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);

  useEffect(() => {
    // arms the fill transitions one frame after mount so a value that is
    // already complete on first paint renders filled instantly, not animated
    const id = requestAnimationFrame(() => setLive(true));
    return () => cancelAnimationFrame(id);
  }, []);

  const flat = useMemo(() => flattenSchema(schema), [schema]);
  const filled = useMemo(() => flat.filter((path) => getAtPath(value, path).present).length, [flat, value]);
  const total = flat.length;

  useEffect(() => {
    if (announceTimer.current) clearTimeout(announceTimer.current);
    announceTimer.current = setTimeout(() => {
      setAnnounced(`${filled} of ${total} field${total === 1 ? "" : "s"} complete`);
    }, ANNOUNCE_DEBOUNCE_MS);
    return () => {
      if (announceTimer.current) clearTimeout(announceTimer.current);
    };
  }, [filled, total]);

  useEffect(
    () => () => {
      if (copyTimer.current) clearTimeout(copyTimer.current);
    },
    [],
  );

  const handleCopy = async () => {
    const ok = await copyToClipboard(JSON.stringify(value ?? {}, null, 2));
    if (!ok) return;
    setCopied(true);
    if (copyTimer.current) clearTimeout(copyTimer.current);
    copyTimer.current = setTimeout(() => setCopied(false), 1500);
  };

  return (
    <div
      role="region"
      aria-busy={streaming}
      aria-label={label}
      className={`ns-skeleton-schema ${live ? "ns-slip-live" : ""} ${className}`}
    >
      <style>{CSS}</style>

      <div className="mb-3 flex items-center justify-between gap-3">
        <span className="font-mono text-[11px] uppercase tracking-[0.14em] text-ns-muted">{label}</span>
        <button
          type="button"
          className="ns-slip-copy rounded-sm border border-border px-2 py-1 font-mono text-[11px] text-ns-muted transition-colors duration-150 hover:text-foreground"
          onClick={handleCopy}
          aria-label={copied ? `${label} copied to clipboard` : `Copy ${label} as JSON`}
        >
          {copied ? "Copied" : "Copy JSON"}
        </button>
      </div>

      <dl className="ns-slip-dl">{schema.map((field) => renderField(field, value, 0, "root"))}</dl>

      <p aria-live="polite" className="sr-only">
        {announced}
      </p>
    </div>
  );
}

The usage example imports ./component because it sits next to the component file in this repo. Installed, that file lands at components/ui/skeleton-schema.tsx, so point the import there.

Use when a structured LLM response whose shape is known before its values are (a tool call's arguments, a JSON-mode answer), so every field and array slot renders as a dashed mold the instant the schema resolves and sets solid as values pour in; pick skeleton-develop instead when the incoming content is prose or layout with no schema to project, or streaming-markdown-caret when the output is free text arriving character by character.

Install

npx shadcn add https://design.helpmarq.com/r/skeleton-schema.json

Ask AI

Point an assistant at this component's docs (llms-full.txt) with one click.

Claude, ChatGPT, Grok, and Perplexity open with the prompt already in. Gemini copies it to your clipboard first. Paste it in once the chat opens.

Build spec

Renders the full shape of a structured LLM answer — a tool call's arguments, a JSON-mode response — as a skeleton the instant its schema resolves, before a single value has arrived: `<SlipCast schema={schema} value={value} streaming={isStreaming} label="search_flights arguments" />`. `schema` is an array of `SlotSchema` (`{ key, kind: "string"|"number"|"boolean"|"null"|"object"|"array", fields?, item? }`) describing every field up front; `value` is the accumulated deep-partial object so far, passed fresh on every render exactly like a streaming buffer, and re-derived (never mutated) each time a new key lands. A field absent from `value` (or explicitly `undefined`) reads as pending; any other value, including an explicit `null` on a `kind:"null"` field, reads as set — so a real streamed `null` is honestly rendered as arrived, not confused with 'hasn't shown up yet'. LAYOUT: real `dt`/`dd` pairs inside a real `dl` (nested objects get their own nested `dl`, array items a `dl` per row), keys typeset in Geist Mono at `--ns-muted` and never localized or reformatted, value blanks pre-sized by type — numbers a narrow tabular-nums box, booleans a fixed five-character box (fits both 'true' and 'false' without resizing on flip), null a fixed four-character box, strings full row width so an arbitrarily long value never reflows the row around it. Nesting indents in fixed 16px steps. THE FILL: a pending slot is a 1px dashed `--border` box; the moment its value lands the box crossfades to a 1px solid `--border` box over 150ms ease-out-expo (border-style itself isn't an animatable CSS property, so this is two stacked frames trading opacity, not a literal style tween — visually indistinguishable from one), the value drops in with a 320ms spring overshoot (opacity + translateY 3px -> 0), and a 4px corner tick fades `--ns-muted` -> `--foreground`. A nested object's own container sets the instant its key exists in `value`, independent of how many of its children have filled — a field-granular pending/set state per slot, not one loading flag for the whole payload. ARRAYS: at every moment the array renders `arrived.length + 2` rows — the two ghost rows past the stream head are already on screen, dashed, before their values exist, so the row a value is about to land in never appears out of nowhere; only the trailing ghost buffer grows as items keep arriving. A slot still dashed when `streaming` goes false stays dashed — an honest 'this field never arrived' failure state, not swept away. Content that mounts already-complete (a value with every field present on first paint) skips the fill animation entirely; transitions only arm one frame after mount, via a `live` class, so nothing plays a cascade it didn't earn. DISTINCT FROM skeleton-develop: that component is a generic content-shaped loading placeholder (bars standing in for unknown prose, one loading flag, blocks that develop into unmeasured children on a single transition) — this one is schema-driven and field-granular: the real keys are typeset before any data exists, every individual slot tracks its own pending/set state, and it is built for the exact shape of streaming JSON/tool-call payloads, not prose. It also sits one layer above streaming-markdown-caret: streaming-markdown-caret owns the character-level insertion point inside a value that is itself still arriving token-by-token (a string slot's value can be a streaming-markdown-caret span); this component owns the contract-level shape around it and has no opinion about mid-token rendering. ACCESSIBILITY: the container is `role=region` with `aria-busy` while `streaming`; a visually-hidden `aria-live=polite` node announces batched milestones ('4 of 9 fields complete'), debounced 350ms so a fast burst of arrivals collapses into one announcement rather than one per key; the only focusable control is a Copy JSON button per top-level object, which serializes the current `value` with `JSON.stringify(value, null, 2)` and confirms with a transient label swap rather than a second element. prefers-reduced-motion drops every transition — slots that set simply appear filled, solid, and ticked with no easing. Pure DOM + CSS, zero dependencies, no canvas; every color is a token (--background --foreground --ns-muted --border --ns-accent), with --ns-accent appearing only on the copy button's focus ring.

Props

PropTypeDefaultDescription
schemaSlotSchema[]—the known shape of the answer, available before any value has arrived
valueJsonRecord | undefined—the accumulated value so far — a deep-partial object; absent keys read as pending
streaming?booleanfalsetrue while more values are still expected; drives aria-busy and the milestone copy
label?string"Structured output"accessible name for the region, and the noun the copy button acts on
className?string—extra classes merged onto the rendered root element