Skip to content

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

TagMeaningAllowed attributesContentExample
<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 numberscolor (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-clickablestart (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

TagMeaningAllowed attributesContentEmits event
<md-button>Clickable buttonnone enforced; use kebab-case onlyButton label (text)button-click{ text, clickCount }
<md-toggle />Checkbox + labelchecked="true|false", labelself-closingtoggle{ label, checked }
<md-input />Text inputplaceholder, valueself-closinginput-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.

TagUse caseAllowed attributesContent
<progress />Signal a progress milestonevalue (number), label (string), any custom attrself-closing
<status />Signal a status changestate (string), any custom attrself-closing
<milestone />Mark a significant eventname (string), any custom attrself-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 =: disabled alone (use disabled="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:

  1. Tokenizer emits tokens as the chunk arrives. When it sees <md-plot ...>, the custom-elements plugin's inline matcher claims it.
  2. Parser builds an custom-element token with meta = { tag: 'md-plot', attrs: { width: '480', height: '120', color: '#5cb85c' } } and content = '120,135,128,...'.
  3. Differ detects the new token (none existed before) and emits an insert op.
  4. Renderer calls customElements()'s render(), which does ctx.createElement('md-plot'), applies the filtered attributes, and sets textContent to the content string.
  5. The browser's native customElements registry fires connectedCallback on the md-plot element. 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.