AI

Structured Output

A JSON-mode response rendered through its schema — labelled rows in schema order, kind-aware values, field-level expected-vs-got validation, pending fields while the stream runs, off-schema keys kept, and a raw JSON view.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import {
  AlertCircle,
  Braces,
  Check,
  ChevronRight,
  CircleAlert,
  CircleDashed,
  Copy,
  CopyX,
  ListTree,
  LoaderCircle,

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/structured-output.json

Prompt

Build a React + TypeScript + Tailwind "StructuredOutput" component with zod and
lucide-react, reusing a Badge primitive and a copy-to-clipboard hook. It renders
a JSON-mode model response THROUGH the schema the model was told to follow.

Contract
- A zod contract file is the single source of truth. A schema SUMMARY (not a
  JSON-Schema re-implementation) describes each field:
  { name, label?, kind, required?, nullable?, description?, enumValues?, unit?,
    fields?: Field[], items?: Field }
  with kind in "string" | "number" | "integer" | "boolean" | "enum" | "array" |
  "object". `fields` describes an object's members, `items` describes ONE array
  element. `integer` is separate from `number` because a float in an int field is
  one of the most common defects a model produces.
- The payload is `value: unknown` — parsed but NOT trusted. Typing it as the
  schema's inferred type would delete exactly the cases this component exists for.
- Findings are addressed by PATH, dotted for members and bracketed for entries:
  { path: "orders[2].amount", message, expected?, got?, severity?: "error" |
  "warning" (default "error") }. `expected` / `got` are strings: they are read,
  not re-parsed, and a validator usually has better words than a type name.
- Envelope status is the component's OWN state — "loading" | "empty" | "error" |
  "ready" — independent of whether the answer validates. A ready card with two
  mismatches is the normal case; `status: "error"` means no JSON came back at all.
- Props: status; schema: Field[]; value?: unknown; fieldErrors?: Issue[];
  streaming? (false); streamingPath?; defaultView? ("fields" | "json");
  showOffSchema? (true); maxDepth? (4); maxArrayItems? (8); maxStringLength?
  (240); errorMessage?; onRetry?; emptyState?; label? ("Structured output");
  className plus the rest of the element props, ref forwarded to the <section>.
- Every numeric prop is clamped (maxDepth >= 1, maxArrayItems >= 1,
  maxStringLength >= 24) so a 0 or a NaN can never render an empty card.

Behavior
- Rows follow SCHEMA order, never the payload's key order. A model reorders keys
  between calls; rows that reshuffle on every response can't be proof-read.
- One resolve pass turns (schema, value, fieldErrors) into a node tree and, in
  the same walk, the totals: fields, filled, problems, warnings, off-schema. Array
  ENTRIES carry their problems upward but not their count — otherwise a 40-entry
  list swallows the "12 of 16 fields" progress line whole.
- An object that never arrived is walked only while streaming, where its members
  read as "not yet". After the stream it is ONE missing row, and its members are
  not resolved or counted: a header that asks you to review rows it never drew is
  a header nobody can clear.
- Per-field state machine, in this order: a supplied error wins; otherwise, if the
  field IS the streamingPath, nothing is checked; otherwise a built-in type check
  runs. Absent + streaming = "pending" ("waiting for tokens"); absent + required =
  "missing"; absent + optional = "blank" ("not provided").
- A STREAMED PREFIX IS NOT A WRONG ANSWER. Half of "feature_request" is not a
  member of the enum and half a number is a different number, so the field the
  stream is currently writing is exempt from validation, and every field that has
  not arrived yet reads as "not yet" instead of red. Drop that rule and every
  stream looks broken for a second before it finishes.
- The built-in type check exists because the most common JSON-mode defects are
  valid JSON: "0.82" where a number belongs, "true" where a boolean belongs,
  412.5 in an integer field, an enum value the model invented, null in a field
  with no null case. Each one produces a message plus expected/got.
- An invalid value is rendered VERBATIM, in monospace. Re-formatting it into the
  shape it failed to be ("42" drawn as a right-aligned number) hides the defect
  the row exists to report.
- Keys in the payload that the schema never declared are SHOWN, in their own
  rows, marked off-schema. A model answering with an extra "reasoning" key is
  telling you your prompt and your schema disagree; dropping it hides the
  evidence.
- Kind-aware rendering: boolean → a check/cross chip carrying the literal
  true/false; enum → a badge plus how many values were allowed; number → tabular
  figures with an optional unit; string → text clamped at maxStringLength with the
  exact character count and a Show-all toggle; array of scalars → a chip row;
  array of objects → numbered sub-groups; object → a collapsible group whose
  header states "3 / 4 fields" and any problem count. Depth past maxDepth falls
  back to inline JSON rather than an unreadable indent staircase.
- Truncation is never silent: "Show 3 more" on a capped array, an exact character
  figure on a clamped string and on the raw panel, and Copy always writes the FULL
  payload, never the visible preview.
- A payload that is not an object (the model answered in prose) is reported ONCE
  at the top — "the response is string …, not an object" — instead of drawing
  twelve identical "missing" rows.
- An "Only issues (N)" toggle filters to rows with a finding, keeping their
  parents; turning it ON expands every group first, because a finding hidden
  inside a collapsed group would make the filter lie. It reaches array entries
  too: two wrong entries out of forty show two rows. The toggle appears when
  there is something to filter AND stays mounted while it is on, so a next
  answer with zero findings cannot strand the card filtered down to nothing.
- Fields and Raw JSON are a real tablist with roving tabindex, arrow keys and
  Home/End; only the selected panel is mounted, because a hidden one keeps every
  toggle and copy button in the tab order — an invisible keyboard trap.
- The raw view pretty-prints with a plain JSON.stringify first and only falls
  back to a cycle/BigInt-safe replacer if that throws, so ordinary payloads are
  exact and pathological ones still render.
- Identity hygiene: when status goes back to "loading" (a new request), per-path
  disclosure and filter state are cleared during render, so paths from the
  previous answer can never be applied to the next one.

Rendering & styling
- Semantic tokens only: bg-card / border / text-muted-foreground for chrome,
  bg-primary for the progress fill and the true-chip accent, text-destructive
  with border-destructive/40 and bg-destructive/5 for problems, bg-muted for
  skeletons and code blocks. No hard-coded colours anywhere.
- Field rows are a real <dl>: each row is a div holding a <dt> (label, JSON key
  in monospace, type chip, required/optional, description) and a <dd> (the value,
  then its findings). Groups put the disclosure button in the <dt> and the nested
  <dl> in the <dd>, which is exactly what the definition-list content model asks
  for and what a screen reader reads as term/definition pairs.
- One persistent sr-only role="status" line spells out the state ("streaming ·
  12 of 16 fields received · 2 need attention"), mounted for the whole ready
  branch so a CHANGE is announced from an element the reader already watches.
  The header carries a role="progressbar" with the same numbers.
- A value cell is keyed on presence, so a field that lands remounts and plays a
  motion-safe fade — no timer, no observer, and nothing to clean up. The
  streaming spinner and every transition are motion-reduce guarded.
- Long keys and long values use `wrap-anywhere` plus `min-w-0`, NOT break-words:
  only overflow-wrap:anywhere lowers the min-content width, which is what stops a
  60-character identifier from making the card wider than its container. The raw
  panel keeps its own horizontal scroll.
- cn() merges the consumer's className into the root <section>, which also
  spreads the remaining element props and forwards its ref.

Customization levers
- Density: `maxDepth` (how deep rows go before inline JSON), `maxArrayItems` (how
  many entries before "Show N more"), `maxStringLength` (how much of a long string
  is drawn). Raise them for a debugging surface, lower them for a review queue —
  but never make any of them silent; the count is part of the reading.
- Which sub-blocks exist: `showOffSchema={false}` drops the extra-key rows for a
  customer-facing surface, `defaultView="json"` opens on the payload for an
  engineer, `emptyState` replaces the empty body, `errorMessage` + `onRetry` own
  the envelope failure.
- Policy: the schema summary itself is the biggest lever — `label` per field
  turns keys into human wording, `description` explains what the model was asked
  for, `unit` puts "USD" or "days" after a number, `required` decides what counts
  as a problem, `nullable` decides whether null is an answer.
- Validation source: pass `fieldErrors` from zod/ajv/your own rules and they win
  over the built-in type check on that path — that is where business rules ("a
  free-plan account can't be refund-eligible") live, as severity "warning" so
  they mark the row without calling it invalid.
- Streaming: set `streaming` while tokens arrive and `streamingPath` to the field
  being written; both are plain data, so the card renders identically on the
  server, in a replay and in a screenshot.
- Chrome: `label` names the region and titles the card; drop it into a narrower
  column and the two-column rows stack by themselves.

Concepts

  • Schema-driven rendering — the schema, not the payload, decides what a row is and in which order it appears. Missing keys therefore still have rows, extra keys are visibly extra, and two consecutive answers to the same prompt line up field by field instead of reshuffling with the model's key order.
  • Expected versus got — a mismatch is only useful next to what was wanted: "expected integer · got number 412.5". The pair is stated on the row that owns it, and the offending value is left verbatim, because a value redrawn in the shape it failed to be reads as correct.
  • A streamed prefix is not a wrong answer — while the stream is mid-field, the value on screen is a prefix: half an enum member, half a number. Validation is suspended for that one path and resumes the moment the stream moves on, which is the difference between a card that flickers red on every response and one that stays readable.
  • Absent has two meanings — "not yet" and "never came". The same empty field reads as a pending shimmer during a stream and as a red missing row after it, and only the second one counts toward the problem total.
  • Off-schema keys are findings, not noise — the model answering with a key nobody asked for is evidence that the prompt and the schema disagree. The row stays, marked, and joins the same finding count as a type error.
  • Announced truncation — every cap says its own size ("Show 3 more", "Showing 240 of 551 characters") and Copy always writes the whole payload. A cap you can see is a cap you can trust; a silent one makes you debug the wrong thing.

On This Page