A Svelte 5 runtime that turns a JSON spec into a live, interactive UI.
Ripple is the rendering layer for AI-generated interfaces. An LLM (or any other source) emits a small JSON document describing what the UI should look like; Ripple mounts it as a real Svelte component tree with state, two-way bindings, expression evaluation, and event handling. The model writes structure; Ripple handles reactivity.
You don't author Svelte components per screen. You hand Ripple a spec and it builds the UI.
{
"version": "1.0",
"state": { "name": "" },
"ui": {
"type": "flex",
"props": { "direction": "column", "gap": "12px" },
"children": [
{ "type": "input", "bind": "name", "props": { "label": "Your name" } },
{ "type": "text", "props": { "text": "Hello, {state.name}!" } }
]
}
}
That spec is a fully working two-way-bound form. No glue code.
Three things, in order of how often you'll touch them:
Renders 150+ typed widgets from JSON. Every node in the tree ({ "type": "...", "props": {...}, "children": [...] }) maps to a Svelte component — flex, card, kanban, chart, comparison-layout, wizard-layout, pricing-table, all of them. The full catalog with prop schemas lives in dist/manifest.json.
Wires state and interactivity declaratively. state is a top-level object on the spec. Inputs use "bind": "<state-path>" for two-way binding. Buttons fire on_click action chains (set, push, remove, toggle, validate, branch, confirm, api, navigate, toast, emit). Expressions inside any string — {state.count + 1}, {state.x > 0 ? 'yes' : 'no'}, {item.price} — are resolved at render time against state and loop context.
Hands back events. Anything the host needs to handle (HTTP calls, navigation, toasts, custom signals) bubbles out via a single onEvent callback on the <Ripple> component. The spec doesn't reach into your app; it asks the host to do something.
The pipeline in five steps:
spec (JSON)
│
▼
┌──────────────┐ UISpec → UniversalSpec wrapper
│ normalizer │ (or pass-through if already universal)
└──────┬───────┘
▼
┌──────────────┐ Svelte 5 $state proxy with dot-notation
│ StateManager │ `get("user.profile.name") / set("count", 7)`
└──────┬───────┘
▼
┌──────────────┐ Resolves `{state.x + 1}`, `{item.field}`,
│ Expression │ ternaries, comparisons, method calls.
│ Resolver │ Tracks reads inside derived blocks for
└──────┬───────┘ automatic reactivity.
▼
┌──────────────┐ Walks the tree, picks the Svelte component
│ NodeRenderer │ from the registry per `type`, evaluates
└──────┬───────┘ `show`/`bind`/`on_*`, renders children.
▼
┌──────────────┐ User clicks → `on_click` chain runs through
│ Event │ the dispatcher: state mutations stay local,
│ Dispatcher │ side-effecting actions (api/toast/navigate)
└──────────────┘ fire `onEvent` on the host.
Each piece is independent and Svelte 5 native:
src/lib/core/normalizer.ts) accepts either spec format and returns a UniversalSpec.src/lib/core/state-manager.svelte.ts) wraps a Svelte 5 $state proxy. Path-based reads/writes auto-create intermediate objects. Subscribe via stateManager.subscribe(callback).src/lib/core/expression-resolver.ts) parses {...} strings — paths, arithmetic, comparisons, ternaries, optional chaining, whitelisted methods (.includes, .length, .toFixed, etc.). Detects UINode-shaped subtrees and leaves them raw so they can resolve later in their own loop context.src/lib/core/event-dispatcher.ts) executes action chains. State actions (set/push/remove/toggle) write through StateManager; control actions (branch/validate/confirm) gate further actions; effect actions (api/navigate/toast/emit/pin/unpin) emit RippleEvents the host can intercept.src/lib/components/NodeRenderer.svelte) is the recursive walker. It evaluates show conditions, resolves props, builds loop contexts for each, manages bind two-way wiring, and renders children — including named slots (header, footer, sidebar, topbar, actions).src/lib/widgets/index.ts) maps type strings to Svelte components. Aliases are flat entries (e.g. dialog → Modal, comparison-cards → ComparisonLayout). Register your own at runtime with registerWidget(type, component).Reactivity is end-to-end: every state mutation flows through the proxy, Svelte recomputes any derived that read it, and only the affected widget re-renders. There is no virtual DOM diff phase you have to think about — it's the same system Svelte 5 uses for hand-written components.
Three reasons that compound:
dist/manifest.json (150 widgets, ~270 KB). A model can fetch it once and have a complete catalog, including runnable examples. New widgets ship by adding to the registry — no SDK update on the agent side.{state.user.name} syntax with comparisons, ternary, and logical operatorsdist/manifest.json ships with every release, declaring every widget's prop schema and a runnable example. Agents can fetch it at runtime to learn the APIbun add @ripple-ui/svelte
Requires Svelte 5 (^5.0.0).
<script lang="ts">
import { Ripple } from '@ripple-ui/svelte';
const spec = {
version: '1.0',
state: { count: 0 },
ui: {
type: 'flex',
props: { direction: 'column', gap: 4 },
children: [
{ type: 'heading', props: { text: 'Counter', level: 2 } },
{ type: 'text', props: { text: 'Count: {state.count}' } },
{
type: 'button',
props: { label: '+1' },
on_click: { action: 'set', target: 'count', value: '{state.count}' }
}
]
}
};
</script>
<Ripple {spec} />
Explicit widget tree with props, events, and control flow:
{
"version": "1.0",
"state": { "query": "" },
"ui": {
"type": "flex",
"props": { "direction": "column", "gap": 3 },
"children": [
{ "type": "input", "props": { "placeholder": "Search..." }, "bind": "{state.query}" },
{
"type": "if",
"condition": "{state.query != ''}",
"children": [{ "type": "text", "props": { "text": "Searching: {state.query}" } }]
}
]
}
}
Declare what the UI should do, and Ripple picks the layout:
{
"version": "2.0",
"intent": "browse",
"title": "Products",
"data": {
"items": [
{ "id": "1", "name": "Widget", "image": "/img/widget.jpg", "price": "$9.99" }
]
},
"fields": { "title": "name", "image": "image", "subtitle": "price" },
"selection": "single"
}
| Category | Examples | Count |
|---|---|---|
| Layout | flex, grid, card, tabs, accordion, split, master-detail, app-shell, sidebar, page-header, hero, section, breadcrumb, dashboard |
21 |
| Display | text, heading, image, badge, metric, stat, progress, progress-ring, avatar, markdown, code-block, kbd, chip, quote, definition-list, comparison-table, pros-cons, steps, link-preview, qr, diff |
33 |
| Input | button, input, textarea, select, combobox, multi-select, checkbox, switch, radio-group, slider, rating, date-picker, time-picker, number-input, segmented, color-picker, file-upload, form, filter-bar, search, location-picker |
24 |
| Data | table, data-grid, chart, kanban, gantt, calendar, timeline, tree, tree-table, virtual-list, sparkline, gauge, funnel, heatmap, sankey, treemap, map |
18 |
| Overlay | alert, callout, tooltip, popover, dropdown-menu, toast, command-palette, context-menu, notification-center, error-state, coachmark |
12 |
| Composite layouts | comparison-layout, entity-detail, form-layout, wizard-layout, checklist-layout, report-layout, invoice-layout, order-status, plus dashboard variants (exec/ops/analytics/pipeline/project), terminal, workflow, c4 |
16 |
| Research | source-card, citation, sources-bar, discover-card, follow-up, kv-table, news-card, ticker, company-header, callout, analyst-bar, range-bar |
13 |
| Vertical | pricing-table, settings-list, comment-thread, audit-log, api-key, people-picker, permission-matrix, org-chart, invoice-lines, bulk-action-bar, saved-views |
11 |
| Control | if, each |
2 |
The complete prop schema and a runnable example for every widget live in dist/manifest.json (regenerated by bun run build:manifest). LLM agents fetch this manifest to learn the API; treat it as the source of truth.
Reactive bindings using {expression} syntax:
{state.user.name} — State path
{item.price} — Loop variable
{state.count > 0} — Comparison
{state.active ? 'On' : 'Off'} — Ternary
{state.a && state.b} — Logical AND
{!state.loading} — Negation
Declarative handlers with 8 action types:
{
"on_click": [
{ "action": "set", "target": "loading", "value": true },
{ "action": "api", "url": "/api/save", "method": "POST" },
{ "action": "toast", "message": "Saved!", "variant": "success" }
]
}
| Action | Behavior |
|---|---|
set |
Update state |
open |
Set state to true (dialog shorthand) |
api |
HTTP request (emitted to parent) |
navigate |
URL navigation (emitted to parent) |
toast |
Show notification (emitted to parent) |
emit |
Custom event (emitted to parent) |
pin / unpin |
Sidebar persistence (emitted to parent) |
import { registerWidget } from '@ripple-ui/svelte';
import MyWidget from './MyWidget.svelte';
registerWidget('my-widget', MyWidget);
bun install # Install dependencies
bun run dev # Dev server with playground (also serves /manifest.json)
bun run build # Build library + manifest to dist/
bun run build:manifest # Regenerate dist/manifest.json and static/manifest.json
bun run check # Type-check
bun run test # Run tests
The dev server serves the widget manifest at http://localhost:5174/manifest.json. Point your LLM tooling at this URL to develop against live changes.
Full documentation in docs/:
MIT