* feat: add @json-render/ink terminal renderer and ink-chat example Adds a new `@json-render/ink` package that brings json-render specs to terminal UIs via Ink (React for CLIs). Includes 24 standard components (layout, text, inputs, markdown, etc.), context providers for state, validation, visibility, actions, focus, and repeat scopes, plus a streaming JSONL hook for progressive spec rendering. Also adds an `ink-chat` example app demonstrating an AI chat interface in the terminal with streaming responses, tool calls, and interactive wizard flows. Includes 76 tests (24 unit + 52 e2e), docs page, and web app integration. * feat: show live spec preview during streaming Render the spec progressively as JSONL patches arrive instead of only showing a spinner. The preview disappears when streaming completes and the finalized message replaces it in history. * feat: clip streaming preview to 6 lines * refactor: move spinner into input box, show full streaming preview * fix: skip spacer during streaming to prevent clipping tall previews * fix: strip invisible colors on dark terminal backgrounds Drop foreground colors like "black" and "#000000" from AI-generated specs so text remains readable on dark terminals. Removed hardcoded color="black" from Badge and added safeColor() guard to all components that accept user-specified color props. * fix: always show spacer to keep input pinned to bottom * fix: show dash placeholder for empty values in Table and KeyValue Empty or null values in Table cells and KeyValue now render as "—" instead of blank space. Also removed dimColor from ListItem subtitle and trailing for better readability on dark terminals. * fix: division by zero in Sparkline sampling when maxWidth is 1 * fix: Review feedback Signed-off-by: Alexis Rico <sferadev@gmail.com> * fix: Update readme Signed-off-by: Alexis Rico <sferadev@gmail.com> --------- Signed-off-by: Alexis Rico <sferadev@gmail.com>
18 KiB
json-render
The Generative UI framework.
Generate dynamic, personalized UIs from prompts without sacrificing reliability. Predefined components and actions for safe, predictable output.
# for React
npm install @json-render/core @json-render/react
# for React with pre-built shadcn/ui components
npm install @json-render/shadcn
# or for React Native
npm install @json-render/core @json-render/react-native
# or for video
npm install @json-render/core @json-render/remotion
# or for PDF documents
npm install @json-render/core @json-render/react-pdf
# or for HTML email
npm install @json-render/core @json-render/react-email @react-email/components @react-email/render
# or for Vue
npm install @json-render/core @json-render/vue
# or for Svelte
npm install @json-render/core @json-render/svelte
# or for SolidJS
npm install @json-render/core @json-render/solid
# or for terminal UIs
npm install @json-render/core @json-render/ink ink react
# or for 3D scenes
npm install @json-render/core @json-render/react-three-fiber @react-three/fiber @react-three/drei three
Why json-render?
json-render is a Generative UI framework: AI generates interfaces from natural language prompts, constrained to components you define. You set the guardrails, AI generates within them:
- Guardrailed - AI can only use components in your catalog
- Predictable - JSON output matches your schema, every time
- Fast - Stream and render progressively as the model responds
- Cross-Platform - React, Vue, Svelte, Solid (web), React Native (mobile) from the same catalog
- Batteries Included - 36 pre-built shadcn/ui components ready to use
Quick Start
1. Define Your Catalog
import { defineCatalog } from "@json-render/core";
import { schema } from "@json-render/react/schema";
import { z } from "zod";
const catalog = defineCatalog(schema, {
components: {
Card: {
props: z.object({ title: z.string() }),
description: "A card container",
},
Metric: {
props: z.object({
label: z.string(),
value: z.string(),
format: z.enum(["currency", "percent", "number"]).nullable(),
}),
description: "Display a metric value",
},
Button: {
props: z.object({
label: z.string(),
action: z.string(),
}),
description: "Clickable button",
},
},
actions: {
export_report: { description: "Export dashboard to PDF" },
refresh_data: { description: "Refresh all metrics" },
},
});
2. Define Your Components
import { defineRegistry, Renderer } from "@json-render/react";
const { registry } = defineRegistry(catalog, {
components: {
Card: ({ props, children }) => (
<div className="card">
<h3>{props.title}</h3>
{children}
</div>
),
Metric: ({ props }) => (
<div className="metric">
<span>{props.label}</span>
<span>{format(props.value, props.format)}</span>
</div>
),
Button: ({ props, emit }) => (
<button onClick={() => emit("press")}>{props.label}</button>
),
},
});
3. Render AI-Generated Specs
function Dashboard({ spec }) {
return <Renderer spec={spec} registry={registry} />;
}
That's it. AI generates JSON, you render it safely.
Packages
| Package | Description |
|---|---|
@json-render/core |
Schemas, catalogs, AI prompts, dynamic props, SpecStream utilities |
@json-render/react |
React renderer, contexts, hooks |
@json-render/vue |
Vue 3 renderer, composables, providers |
@json-render/svelte |
Svelte 5 renderer with runes-based reactivity |
@json-render/solid |
SolidJS renderer with fine-grained reactive contexts |
@json-render/shadcn |
36 pre-built shadcn/ui components (Radix UI + Tailwind CSS) |
@json-render/react-three-fiber |
React Three Fiber renderer for 3D scenes (19 built-in components) |
@json-render/react-native |
React Native renderer with standard mobile components |
@json-render/remotion |
Remotion video renderer, timeline schema |
@json-render/react-pdf |
React PDF renderer for generating PDF documents from specs |
@json-render/react-email |
React Email renderer for HTML/plain-text emails from specs |
@json-render/ink |
Ink terminal renderer with built-in components for interactive TUIs. |
@json-render/image |
Image renderer for SVG/PNG output (OG images, social cards) via Satori |
@json-render/codegen |
Utilities for generating code from json-render UI trees |
@json-render/redux |
Redux / Redux Toolkit adapter for StateStore |
@json-render/zustand |
Zustand adapter for StateStore |
@json-render/jotai |
Jotai adapter for StateStore |
@json-render/xstate |
XState Store (atom) adapter for StateStore |
@json-render/mcp |
MCP Apps integration for Claude, ChatGPT, Cursor, VS Code |
@json-render/yaml |
YAML wire format with streaming parser, edit modes, AI SDK transform |
Renderers
React (UI)
import { defineRegistry, Renderer } from "@json-render/react";
import { schema } from "@json-render/react/schema";
// Flat spec format (root key + elements map)
const spec = {
root: "card-1",
elements: {
"card-1": {
type: "Card",
props: { title: "Hello" },
children: ["button-1"],
},
"button-1": {
type: "Button",
props: { label: "Click me" },
children: [],
},
},
};
// defineRegistry creates a type-safe component registry
const { registry } = defineRegistry(catalog, { components });
<Renderer spec={spec} registry={registry} />;
Vue (UI)
import { h } from "vue";
import { defineRegistry, Renderer } from "@json-render/vue";
import { schema } from "@json-render/vue/schema";
const { registry } = defineRegistry(catalog, {
components: {
Card: ({ props, children }) =>
h("div", { class: "card" }, [h("h3", null, props.title), children]),
Button: ({ props, emit }) =>
h("button", { onClick: () => emit("press") }, props.label),
},
});
// In your Vue component template:
// <Renderer :spec="spec" :registry="registry" />
Svelte (UI)
import { defineRegistry, Renderer } from "@json-render/svelte";
import { schema } from "@json-render/svelte/schema";
const { registry } = defineRegistry(catalog, {
components: {
Card: ({ props, children }) => /* Svelte 5 snippet */,
Button: ({ props, emit }) => /* Svelte 5 snippet */,
},
});
// In your Svelte component:
// <Renderer spec={spec} registry={registry} />
Solid (UI)
import { defineRegistry, Renderer } from "@json-render/solid";
import { schema } from "@json-render/solid/schema";
const { registry } = defineRegistry(catalog, {
components: {
Card: (renderProps) => <div>{renderProps.children}</div>,
Button: (renderProps) => (
<button onClick={() => renderProps.emit("press")}>
{renderProps.element.props.label as string}
</button>
),
},
});
<Renderer spec={spec} registry={registry} />;
shadcn/ui (Web)
import { defineCatalog } from "@json-render/core";
import { schema } from "@json-render/react/schema";
import { defineRegistry, Renderer } from "@json-render/react";
import { shadcnComponentDefinitions } from "@json-render/shadcn/catalog";
import { shadcnComponents } from "@json-render/shadcn";
// Pick components from the 36 standard definitions
const catalog = defineCatalog(schema, {
components: {
Card: shadcnComponentDefinitions.Card,
Stack: shadcnComponentDefinitions.Stack,
Heading: shadcnComponentDefinitions.Heading,
Button: shadcnComponentDefinitions.Button,
},
actions: {},
});
// Use matching implementations
const { registry } = defineRegistry(catalog, {
components: {
Card: shadcnComponents.Card,
Stack: shadcnComponents.Stack,
Heading: shadcnComponents.Heading,
Button: shadcnComponents.Button,
},
});
<Renderer spec={spec} registry={registry} />;
React Native (Mobile)
import { defineCatalog } from "@json-render/core";
import { schema } from "@json-render/react-native/schema";
import {
standardComponentDefinitions,
standardActionDefinitions,
} from "@json-render/react-native/catalog";
import { defineRegistry, Renderer } from "@json-render/react-native";
// 25+ standard components included
const catalog = defineCatalog(schema, {
components: { ...standardComponentDefinitions },
actions: standardActionDefinitions,
});
const { registry } = defineRegistry(catalog, { components: {} });
<Renderer spec={spec} registry={registry} />;
Remotion (Video)
import { Player } from "@remotion/player";
import {
Renderer,
schema,
standardComponentDefinitions,
} from "@json-render/remotion";
// Timeline spec format
const spec = {
composition: {
id: "video",
fps: 30,
width: 1920,
height: 1080,
durationInFrames: 300,
},
tracks: [{ id: "main", name: "Main", type: "video", enabled: true }],
clips: [
{
id: "clip-1",
trackId: "main",
component: "TitleCard",
props: { title: "Hello" },
from: 0,
durationInFrames: 90,
},
],
audio: { tracks: [] },
};
<Player
component={Renderer}
inputProps={{ spec }}
durationInFrames={spec.composition.durationInFrames}
fps={spec.composition.fps}
compositionWidth={spec.composition.width}
compositionHeight={spec.composition.height}
/>;
React PDF (Documents)
import { renderToBuffer } from "@json-render/react-pdf";
const spec = {
root: "doc",
elements: {
doc: {
type: "Document",
props: { title: "Invoice" },
children: ["page-1"],
},
"page-1": {
type: "Page",
props: { size: "A4" },
children: ["heading-1", "table-1"],
},
"heading-1": {
type: "Heading",
props: { text: "Invoice #1234", level: "h1" },
children: [],
},
"table-1": {
type: "Table",
props: {
columns: [
{ header: "Item", width: "60%" },
{ header: "Price", width: "40%", align: "right" },
],
rows: [
["Widget A", "$10.00"],
["Widget B", "$25.00"],
],
},
children: [],
},
},
};
// Render to buffer, stream, or file
const buffer = await renderToBuffer(spec);
React Email (Email)
import { renderToHtml } from "@json-render/react-email";
import { schema, standardComponentDefinitions } from "@json-render/react-email";
import { defineCatalog } from "@json-render/core";
const catalog = defineCatalog(schema, {
components: standardComponentDefinitions,
});
const spec = {
root: "html-1",
elements: {
"html-1": {
type: "Html",
props: { lang: "en", dir: "ltr" },
children: ["head-1", "body-1"],
},
"head-1": { type: "Head", props: {}, children: [] },
"body-1": {
type: "Body",
props: { style: { backgroundColor: "#f6f9fc" } },
children: ["container-1"],
},
"container-1": {
type: "Container",
props: {
style: { maxWidth: "600px", margin: "0 auto", padding: "20px" },
},
children: ["heading-1", "text-1"],
},
"heading-1": { type: "Heading", props: { text: "Welcome" }, children: [] },
"text-1": {
type: "Text",
props: { text: "Thanks for signing up." },
children: [],
},
},
};
const html = await renderToHtml(spec);
Image (SVG/PNG)
import { renderToPng } from "@json-render/image/render";
const spec = {
root: "frame",
elements: {
frame: {
type: "Frame",
props: { width: 1200, height: 630, backgroundColor: "#1a1a2e" },
children: ["heading"],
},
heading: {
type: "Heading",
props: { text: "Hello World", level: "h1", color: "#ffffff" },
children: [],
},
},
};
// Render to PNG (requires @resvg/resvg-js)
const png = await renderToPng(spec, { fonts });
// Or render to SVG string
import { renderToSvg } from "@json-render/image/render";
const svg = await renderToSvg(spec, { fonts });
Three.js (3D)
import { defineCatalog } from "@json-render/core";
import { schema, defineRegistry } from "@json-render/react";
import {
threeComponentDefinitions,
threeComponents,
ThreeCanvas,
} from "@json-render/react-three-fiber";
const catalog = defineCatalog(schema, {
components: {
Box: threeComponentDefinitions.Box,
Sphere: threeComponentDefinitions.Sphere,
AmbientLight: threeComponentDefinitions.AmbientLight,
DirectionalLight: threeComponentDefinitions.DirectionalLight,
OrbitControls: threeComponentDefinitions.OrbitControls,
},
actions: {},
});
const { registry } = defineRegistry(catalog, {
components: {
Box: threeComponents.Box,
Sphere: threeComponents.Sphere,
AmbientLight: threeComponents.AmbientLight,
DirectionalLight: threeComponents.DirectionalLight,
OrbitControls: threeComponents.OrbitControls,
},
});
<ThreeCanvas
spec={spec}
registry={registry}
shadows
camera={{ position: [5, 5, 5], fov: 50 }}
style={{ width: "100%", height: "100vh" }}
/>;
Ink (Terminal)
import { defineCatalog } from "@json-render/core";
import {
schema,
standardComponentDefinitions,
standardActionDefinitions,
defineRegistry,
Renderer,
JSONUIProvider,
} from "@json-render/ink";
const catalog = defineCatalog(schema, {
components: { ...standardComponentDefinitions },
actions: standardActionDefinitions,
});
const { registry } = defineRegistry(catalog, { components: {} });
const spec = {
root: "card-1",
elements: {
"card-1": {
type: "Card",
props: { title: "Status" },
children: ["status-1"],
},
"status-1": {
type: "StatusLine",
props: { label: "Build", status: "success" },
children: [],
},
},
};
<JSONUIProvider initialState={{}}>
<Renderer spec={spec} registry={registry} />
</JSONUIProvider>;
Features
Streaming (SpecStream)
Stream AI responses progressively:
import { createSpecStreamCompiler } from "@json-render/core";
const compiler = createSpecStreamCompiler<MySpec>();
// Process chunks as they arrive
const { result, newPatches } = compiler.push(chunk);
setSpec(result); // Update UI with partial result
// Get final result
const finalSpec = compiler.getResult();
AI Prompt Generation
Generate system prompts from your catalog:
const systemPrompt = catalog.prompt();
// Includes component descriptions, props schemas, available actions
Conditional Visibility
{
"type": "Alert",
"props": { "message": "Error occurred" },
"visible": [
{ "$state": "/form/hasError" },
{ "$state": "/form/errorDismissed", "not": true }
]
}
Dynamic Props
Any prop value can be data-driven using expressions:
{
"type": "Icon",
"props": {
"name": {
"$cond": { "$state": "/activeTab", "eq": "home" },
"$then": "home",
"$else": "home-outline"
},
"color": {
"$cond": { "$state": "/activeTab", "eq": "home" },
"$then": "#007AFF",
"$else": "#8E8E93"
}
}
}
Expression forms:
{ "$state": "/state/key" }- reads a value from the state model{ "$cond": <condition>, "$then": <value>, "$else": <value> }- evaluates a condition and picks a branch{ "$template": "Hello, ${/user/name}!" }- interpolates state values into strings{ "$computed": "fn", "args": { ... } }- calls a registered function with resolved args
Actions
Components can trigger actions, including the built-in setState action:
{
"type": "Pressable",
"props": {
"action": "setState",
"actionParams": { "statePath": "/activeTab", "value": "home" }
},
"children": ["home-icon"]
}
The setState action updates the state model directly, which re-evaluates visibility conditions and dynamic prop expressions.
State Watchers
React to state changes by triggering actions:
{
"type": "Select",
"props": {
"value": { "$bindState": "/form/country" },
"options": ["US", "Canada", "UK"]
},
"watch": {
"/form/country": {
"action": "loadCities",
"params": { "country": { "$state": "/form/country" } }
}
}
}
watch is a top-level field on elements (sibling of type/props/children). Watchers fire when the watched value changes, not on initial render.
Demo
git clone https://github.com/vercel-labs/json-render
cd json-render
pnpm install
pnpm dev
- http://json-render.localhost:1355 - Docs & Playground
- http://dashboard-demo.json-render.localhost:1355 - Example Dashboard
- http://react-email-demo.json-render.localhost:1355 - React Email Example
- http://remotion-demo.json-render.localhost:1355 - Remotion Video Example
- Chat Example: run
pnpm devinexamples/chat - Svelte Example: run
pnpm devinexamples/svelteorexamples/svelte-chat - Vue Example: run
pnpm devinexamples/vue - Vite Renderers (React + Vue + Svelte + Solid): run
pnpm devinexamples/vite-renderers - React Native example: run
npx expo startinexamples/react-native
How It Works
flowchart LR
A[User Prompt] --> B[AI + Catalog]
B --> C[JSON Spec]
C --> D[Renderer]
B -.- E([guardrailed])
C -.- F([predictable])
D -.- G([streamed])
- Define the guardrails - what components, actions, and data bindings AI can use
- Prompt - describe what you want in natural language
- AI generates JSON - output is always predictable, constrained to your catalog
- Render fast - stream and render progressively as the model responds
License
Apache-2.0