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.
npx shadcn add https://design.helpmarq.com /r/skeleton-schema.jsonregistry/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 --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 --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 --muted
// --border --accent); --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;
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(--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(--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(--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-muted">{label}</span>
<button
type="button"
className="ns-slip-copy rounded-sm border border-border px-2 py-1 font-mono text-[11px] text-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>
);
}
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 `--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 `--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 --muted --border --accent), with --accent appearing only on the copy button's focus ring.