Appearance
Emitting HTML / Custom Elements from LLMs
Generative DOM does not do HTML passthrough. There is no sanitizer pass, no innerHTML, no DOMPurify. The core renders text via textContent and structural elements via the DOM API — nothing else.
What it does support is an opt-in whitelist of md-* custom elements, delivered as three plugins:
@generative-dom/plugin-custom-elements— stateless rendering elements (md-clock,md-plot,md-counter).@generative-dom/plugin-interactive— stateful interactive elements (md-button,md-toggle,md-input).@generative-dom/plugin-events— invisible event-only elements (<progress>,<status>,<milestone>) that emit events but produce no DOM.
When one of these plugins is registered, matching tags are preserved through the parser and rendered as real web components. Every other HTML tag the LLM emits is either rendered as literal text (unknown tags) or stripped entirely (by being unmatched by any plugin).
Security model
The whitelist is architectural. Unregistered tags cannot sneak through — they produce no HTMLElement output from any plugin, so the renderer has nothing to emit. on* event handler attributes, style, and the reserved data-generative-dom-* namespace are filtered at both the parse stage and the render stage (defense in depth). No CSS-via-attribute, no JavaScript-via-URL — link URLs are whitelist-checked to https:, http:, mailto: only, image URLs to https:, http:.
The tag reference
@generative-dom/plugin-custom-elements — registered by default
| Tag | Meaning | Allowed attributes | Content | Example |
|---|---|---|---|---|
<md-clock> | Live ticking clock (updates every 1 s) | none (attributes filtered on render) | none | <md-clock></md-clock> |
<md-plot> | SVG bar chart of comma-separated numbers | color (CSS color), width (px), height (px) | Comma-separated numbers | <md-plot color="#4a90d9" width="400" height="100">1,2,3,4,5</md-plot> |
<md-counter> | Integer value with +/- buttons, user-clickable | start (int), min (int), max (int) | none | <md-counter start="0" min="0" max="10"></md-counter> |
The whitelist is configurable: customElements({ allowedTags: [...] }) lets you add or restrict tags. If you register additional customElements.define() components in your app, adding them to allowedTags makes them render from LLM output.
@generative-dom/plugin-interactive
| Tag | Meaning | Allowed attributes | Content | Emits event |
|---|---|---|---|---|
<md-button> | Clickable button | none enforced; use kebab-case only | Button label (text) | button-click → { text, clickCount } |
<md-toggle /> | Checkbox + label | checked="true|false", label | self-closing | toggle → { label, checked } |
<md-input /> | Text input | placeholder, value | self-closing | input-change → { value } |
Subscribe in the host app:
ts
md.on('button-click', ({ text, clickCount }) => { /* ... */ });
md.on('toggle', ({ label, checked }) => { /* ... */ });
md.on('input-change', ({ value }) => { /* ... */ });Interactive elements are state-preserving: as long as the raw markdown source of the element doesn't change between chunks, Generative DOM reuses the live DOM node. User-clicked counter state, input-focus state, and toggle state all survive across re-renders.
@generative-dom/plugin-events
These tags emit an event and produce no DOM output. They're invisible.
| Tag | Use case | Allowed attributes | Content |
|---|---|---|---|
<progress /> | Signal a progress milestone | value (number), label (string), any custom attr | self-closing |
<status /> | Signal a status change | state (string), any custom attr | self-closing |
<milestone /> | Mark a significant event | name (string), any custom attr | self-closing |
ts
md.on('progress', (attrs) => { /* attrs.value is auto-parsed as number */ });
md.on('status', (attrs) => { /* attrs.state is a string */ });
md.on('milestone', (attrs) => { /* attrs.name is a string */ });Numeric-looking attribute values are automatically coerced to numbers. These tags are the right tool for LLM side-channels: "I finished research", "I'm switching to code generation", "I've completed step 3".
Prompting patterns
Pattern: Tell the model what's allowed
md
You may emit these custom elements; they render as live web components:
- <md-clock></md-clock> — the current time (no attributes, no content)
- <md-plot color="#4a90d9" width="400" height="120">1,2,3,4</md-plot> — bar chart from comma-separated numbers
- <md-counter start="0" min="0" max="10"></md-counter> — interactive counter
Do not emit any other HTML tags.Pattern: Attribute format rules
Models sometimes emit single-quoted, camelCase, or CSS-laden attributes. Preempt this:
md
When using custom elements:
- Double-quote attribute values: width="400", not width='400' or width=400.
- Use kebab-case attribute names: max-value, not maxValue.
- Do not use the style attribute — inline styles are stripped.Pattern: Content model
Models sometimes try to put children inside md-clock or markdown inside md-plot. Spell out the content model:
md
- <md-clock> takes no content. Self-close or leave empty: <md-clock></md-clock>.
- <md-plot> content is ONLY comma-separated numbers: 1.2,3.4,5.6. No text, no markdown.
- <md-counter> takes no content. Use attributes for config.Pattern: Events, not DOM
If you're using @generative-dom/plugin-events, explain the invisibility:
md
You may emit these event tags to signal progress. They are INVISIBLE to the user — they produce no visible output. Use them to notify the host app of state changes.
- <progress value="0.5" label="downloading"/>
- <status state="searching"/>
- <milestone name="step-3-complete"/>Without that explanation, models will sometimes wrap them in backticks, thinking they're "example syntax" that should render.
Pattern: Demonstrate once in the prompt
For tricky tags, a one-shot example beats any amount of prose:
md
Example of a good response:
### System load
<md-plot width="400" height="100" color="#4a90d9">24,26,31,38,42,48,55,61,58</md-plot>
Load spiked at 1 PM (59% CPU). Back to baseline by 2.Attribute formatting discipline
The parseAttrs implementation accepts:
- Double-quoted:
attr="value" - Single-quoted:
attr='value' - Unquoted for word-chars:
attr=value
It does not accept:
- Attributes without
=:disabledalone (usedisabled="true"instead). - Attribute names starting with a digit:
1count="x"is rejected entirely. - The name
style— always filtered. - Names starting with
on— always filtered (all event handlers are blocked). - Names in the
data-generative-dom-*namespace — reserved for renderer internals.
When duplicate attributes appear, last occurrence wins. <md-plot color="red" color="blue"> renders blue.
Debugging
Symptom: <md-foo> appears as literal text in the output.
Cause: the plugin that matches <md-foo> is not registered. Generative DOM's inline matcher didn't claim the tag, so it fell through to text rendering.
Fix: register the appropriate plugin:
ts
import { customElements } from '@generative-dom/plugin-custom-elements';
import { interactive } from '@generative-dom/plugin-interactive';
import { events } from '@generative-dom/plugin-events';
new GenerativeDom({
container,
plugins: [
// ...markdown plugins
customElements(),
interactive(),
events(),
],
});Symptom: the tag renders as DOM but attributes are missing.
Cause: the attribute name hit the filter — it started with on, was style, or began with data-generative-dom-. Or the name started with a digit, or the attribute value wasn't quoted and contained whitespace.
Fix: rename the attribute to something not on the blocklist; use double quotes if the value contains spaces.
Symptom: the tag renders but the web component does nothing.
Cause: the custom element is defined, but its JS has an error. md-clock and md-plot use connectedCallback hooks — if those throw, the element is in the DOM but inert. Check DevTools console.
Symptom: <md-plot> renders an empty SVG.
Cause: content didn't parse as numbers. <md-plot>a,b,c</md-plot> silently produces an empty chart because Number('a') is NaN, which the component filters out. Same for content that's empty or whitespace-only.
Fix: ensure content is comma-separated numbers (decimals OK, scientific notation like 1e3 OK).
A full round-trip
User asks for a visualisation. The LLM, under a properly-configured system prompt, streams:
md
Here's the last 12 months of signups:
<md-plot width="480" height="120" color="#5cb85c">120,135,128,141,152,160,148,155,164,170,182,195</md-plot>
Growth is roughly linear at +6/mo, with a summer dip in month 7.Generative DOM's pipeline:
- Tokenizer emits tokens as the chunk arrives. When it sees
<md-plot ...>, the custom-elements plugin's inline matcher claims it. - Parser builds an
custom-elementtoken withmeta = { tag: 'md-plot', attrs: { width: '480', height: '120', color: '#5cb85c' } }andcontent = '120,135,128,...'. - Differ detects the new token (none existed before) and emits an
insertop. - Renderer calls
customElements()'srender(), which doesctx.createElement('md-plot'), applies the filtered attributes, and setstextContentto the content string. - The browser's native
customElementsregistry firesconnectedCallbackon themd-plotelement. The component parses its own text content, builds an SVG with bar<rect>s, and replaces its children.
The user sees: a paragraph, a bar chart, another paragraph. All three stream in order. If the LLM later updates the number list (by re-emitting the whole element), Generative DOM's differ spots the token change, the renderer updates the element's text content, and the component's attributeChangedCallback re-renders the SVG. The user's scroll position doesn't move; any selection on the surrounding text survives.
Next
See Generative DOM's Markdown Subset for the full in/out list — what's supported, what's intentionally omitted, and the rationale for each decision.