gen ui demo
Two Claude tool-use calls in series. Stage 1 picks components from a framework-agnostic web-component library and fills them with content. Stage 2 arranges them into a layout. The renderer is plain Svelte. The LLM never writes code.
The demo runs as a standalone app at
gen-ui.jeffmills.dev. Reviewers can clone
the repo and npm run dev in under a minute.
how it works
+ fill content
json component list
layout json
- Stage 1, content + component selection
-
Sonnet- or Opus-tier model with one Anthropic tool definition per
Shoelace component (30 of them, generated from the manifest). The
model returns its choices as
tool_useblocks: one tag per call, with typed attributes, default-slot content, and named slots. The app parses them into a flat array ofComponentInstanceobjects. No JSX, HTML, or CSS ever crosses the wire. - Stage 2, layout decision
-
Haiku-tier model with a single
compose_layouttool. Its schema is a recursive grammar of seven layout primitives (ref,grid,stack,row,section,tabs,sidebar,divider). Output is one layout tree that references Stage 1 components by id. - Stage 3, deterministic render
- A recursive Svelte component walks the layout tree and a sibling renderer materializes each web component with its attributes and slot HTML. Plain application code. No LLM involvement. Predictable, testable, debuggable.
stage 1 prompt: forcing decomposition
The naive prompt produces a model that compresses everything into one
sl-alert with the whole answer as a giant content string.
That throws away every layout opportunity downstream. The fix is to make
decomposition the loudest rule in the system prompt and reinforce it on
every user turn.
The system prompt opens with:
RULE #1 - DECOMPOSE THE ANSWER.
The renderer treats every sl-card / sl-alert / sl-badge as a separate
atom that the next stage can lay out in grids, rows, tabs, and sidebars.
If you pack a multi-part answer into ONE component's content string, all
of that layout capability is wasted and the page looks like a wall of text.
Before you call any tool, COUNT the natural sections in your answer:
- A "2-week itinerary" has 14 sections (one per day) plus flights, tips.
- A "compare X, Y, Z" has 3 sections (one per option) plus a verdict.
- A "dashboard of metrics" has one component per metric.
Then emit roughly that many components.
The user-turn wrapper repeats the directive with even stronger
language and an explicit reminder that parallel tool use is supported,
so the model knows it can emit many tool_use blocks in a
single response. Combined with temperature: 0.2, this
turns a coin-flip behavior into a reliable one. Same prompt, same
model: 1 tool_use vs 19 tool_uses. Below 0.5 the model stops landing
on "one big summary alert" as a valid completion.
stage 2 prompt: forcing layout grouping
Stage 2 has the opposite problem. Given fifteen components, the default behavior is to dump them all into a flat vertical stack: technically correct, visually useless. The job here is to identify groupings (days of a trip, metric tiles, comparison cards) and arrange them as grids, rows, and sections rather than rows of one thing each.
The system prompt is structured around that single rule:
RULE #1 - GROUP, DON'T STACK.
A flat vertical list of 15+ components is almost always wrong. Read every
component's header label and content preview before you decide anything.
Look for:
- Series: items numbered/named in sequence ("Day 1", "Day 2", "Day 3")
-> arrange in a grid (2-4 columns).
- Categories: items sharing a topic (all Tokyo cards together)
-> nest in their own section.
- Metrics / tiles: small components (sl-progress-ring, sl-format-number)
-> group as a dashboard band.
- Primary + supporting: one big thing with small things around it
-> sidebar layout. The prompt is followed by a worked example that draws the desired tree shape for the test prompt ("2-week Japan itinerary"), and an explicit anti-pattern showing the flat-stack output as wrong. The combination of a hard rule plus a worked example plus a negative example reliably produces nested layouts.
passing context between stages
Stage 2 cannot do its job unless it sees the semantic signals Stage 1 embedded in each component. The first version of this pipeline sent Stage 2 just the tag name and attributes:
0. <sl-card> variant="primary"
1. <sl-card> variant="primary"
2. <sl-card> variant="primary"
...
From the model's perspective, that's fifteen indistinguishable cards.
Of course it produces a flat stack. The fix was to include the most
grouping-relevant field: the header slot, which is where
Stage 1 puts the card's label.
0. <sl-card> header="Flight: PHL -> HND" content="Outbound ANA NH9 dep PHL 12:35..."
1. <sl-card> header="Day 1 - Tokyo: Asakusa" content="Morning at Senso-ji, afternoon..."
2. <sl-card> header="Day 2 - Shibuya & Harajuku" content="..."
...
14. <sl-card> header="Day 14 - Return to PHL" content="..." With those header labels, Stage 2 immediately recognizes "days 1-14 are a sequence" and grids them, and "the flight is its own thing" and breaks it out. Same model, same prompt, dramatically different output. The reference-card format is a tiny serialization step in the Pages Function, but it's the load-bearing piece that lets the two stages reason about the same content from different angles.
rendering: getting svelte out of the way
The renderer is two recursive Svelte components and one global stylesheet. The interesting parts are where Svelte's defaults fight the goal.
Slot HTML, not slot text. The LLM emits things like
<sl-icon name="exclamation-triangle"></sl-icon> into a
component's icon slot. If the renderer treats that as
text content the user sees escaped markup. ComponentRenderer
parses each slot value as HTML and appends the resulting nodes,
preserving the nested custom elements so they upgrade correctly.
Global layout primitives, not scoped CSS. Svelte's
scoped-CSS pass adds a class hash to every selector inside a component,
including the universal selector inside
> * + * spacing rules. Adjacent-sibling selectors
then only match children that carry the same hash, which excludes
every Shoelace element rendered in a different component. The
layout primitives (.gen-stack, .gen-grid,
.gen-row, .gen-section) live in a global
stylesheet so the lobotomized-owl pattern applies across every kind
of child element, framework-rendered or otherwise.
Recursive layout walker. The
LayoutTree.svelte component switches on
node.kind and recurses into itself for each container
type. grid sets
grid-template-columns: repeat(N, 1fr);
row and stack use flexbox with a
data-gap attribute that maps to a named gap size from
the global stylesheet. The tree shape is purely declarative; styling
is purely CSS. No layout decisions live in JavaScript.
Content normalization. The LLM sometimes falls back
to <br><br> for paragraph breaks even when the
prompt asks for semantic HTML. The renderer rewrites those into real
<p> blocks before injecting them, so the typography
CSS for cards and alerts has elements with sensible margins to style
rather than a wall of inline text.
what this proved
- The composition / rendering split holds. The LLM reasons about which components and what content; the application code draws. Each side is good at its job.
- Tool schemas are the contract. Constraining all model output to typed JSON eliminates the code-correctness and injection risks that block production use of free-form LLM output.
- The manifest is the API surface. Because the 30 tool
definitions are generated from
custom-elements.jsonat build time, the design system stays the source of truth. Adding a Shoelace component or attribute propagates to the LLM’s tool surface on the next build with zero handwritten glue. - Stages need different prompts and different signals. Stage 1 has to be pushed toward decomposition; Stage 2 has to be pushed toward grouping and given the right semantic signals (slot labels, not just tag names) to act on. Same tool-use API, two distinct prompt-engineering problems.
- A well-designed design system is the substrate. Framework-agnostic web components with constrained props were the right shape for this years before generative UI was a real category. The original prototype validated Meridian’s architecture as durable against the GenAI paradigm shift.
stack
- Frontend
- Astro, Svelte 5 (runes), Shoelace 2.x web components, Tailwind v4 for tokens
- Pipeline
- Anthropic Messages API, two-stage tool use, prompt caching on tools + system prompt, low-temperature stages for consistency
- Tool generation
-
Build-time parser over Shoelace’s
custom-elements.jsonmanifest, emitting one Anthropic tool spec per component - Hosting
- Cloudflare Pages + Pages Functions, key in Pages env vars
- Original prototype
- Svelte, Node.js, AWS Bedrock with Claude tool use, Meridian components
Full write-up: Generating UI Without Generating Code · live demo · source