Rendering Dynamic HTML in LWC Shadow DOM
Overview
Lightning Web Components use Shadow DOM to isolate component styles, which is normally a benefit. However, when you need to render dynamically generated HTML -- such as AI chatbot responses, rich text from APIs, or markdown conversions -- this isolation becomes a barrier. Standard CSS cannot reach into injected HTML content, leaving it completely unstyled.
This article explains why common approaches (like lightning-formatted-rich-text and component-level CSS) fail for dynamic HTML, and presents the reliable solution: embedding inline styles directly on generated elements before injecting them via lwc:dom="manual". You will learn the technique, key security considerations around XSS prevention, and when this pattern is the right choice versus standard template rendering.
Rendering Dynamic HTML in LWC Shadow DOM
Lightning Web Components use Shadow DOM to encapsulate styles and markup. This is normally a benefit -- component styles cannot leak out or bleed in. But when you need to render dynamically generated HTML (AI responses, rich text from APIs, markdown-to-HTML conversions), Shadow DOM becomes a barrier: external and component-level CSS cannot reach into injected innerHTML.
The Problem
Consider a chatbot component that receives markdown from an LLM API and needs to display formatted tables, code blocks, and lists. Two common approaches fail:
lightning-formatted-rich-text-- strips many HTML tags for security. Tables, custom classes, and most structural HTML get removed entirely.- Component-level CSS -- styles defined in your
.cssfile do not penetrate intoinnerHTMLcontent because Shadow DOM scopes them to the component's template-rendered DOM, not dynamically injected nodes.
<!-- This renders, but your .css file cannot style it -->
<div lwc:dom="manual" class="message-body"></div>
// Injected HTML ignores component CSS entirely
this.template.querySelector('.message-body').innerHTML = '<table><tr><td>Data</td></tr></table>';
The table renders unstyled -- no borders, no padding, no background colors.
The Solution: Inline Styles in Generated HTML
Since Shadow DOM blocks external CSS from reaching injected content, the only reliable approach is to embed styles directly on every HTML element before setting innerHTML.
_markdownToHtml(md) {
let html = this._escapeHtml(md);
// Define inline styles as constants
const tableStyle = 'width:100%;border-collapse:collapse;margin:8px 0;font-size:13px;';
const thStyle = 'padding:6px 10px;border:1px solid #374151;background:#1f2937;color:#e5e7eb;text-align:left;';
const tdStyle = 'padding:6px 10px;border:1px solid #374151;color:#d1d5db;';
const codeBlockStyle = 'background:#1e1e2e;color:#cdd6f4;padding:12px;border-radius:6px;overflow-x:auto;font-size:13px;';
// Parse markdown tables into styled HTML
html = html.replace(/(\|.+\|[\r\n]+\|[-| :]+\|[\r\n]+((\|.+\|[\r\n]*)+))/gm,
(tableBlock) => {
const rows = tableBlock.trim().split('\n').filter(r => !r.match(/^\|[-| :]+\|$/));
let table = `<table style="${tableStyle}">`;
rows.forEach((row, i) => {
const cells = row.split('|').filter(c => c.trim() !== '');
const tag = i === 0 ? 'th' : 'td';
const style = i === 0 ? thStyle : tdStyle;
table += '<tr>' + cells.map(c =>
`<${tag} style="${style}">${c.trim()}</${tag}>`
).join('') + '</tr>';
});
return table + '</table>';
}
);
// Code blocks with inline styling
html = html.replace(/```(\w*)\n([\s\S]*?)```/gm,
(_, lang, code) => `<pre style="${codeBlockStyle}"><code>${code}</code></pre>`
);
return html;
}
Setting innerHTML with lwc:dom="manual"
LWC prohibits direct DOM manipulation by default. The lwc:dom="manual" directive opts a container out of LWC's template rendering, allowing safe use of innerHTML.
<template>
<div lwc:dom="manual" class="content-container"></div>
</template>
Set content in renderedCallback or after an async operation:
renderedCallback() {
if (this._contentChanged) {
const container = this.template.querySelector('.content-container');
if (container) {
container.innerHTML = this._processedHtml;
this._contentChanged = false;
}
}
}
Key Considerations
| Concern | Approach |
|---|---|
| XSS prevention | Escape user-provided content before injecting. Never set innerHTML with raw, unescaped input. |
| Style maintenance | Define style strings as constants at the top of the method. Changing a color updates every element. |
| Performance | Avoid re-rendering on every cycle. Use a dirty flag (_contentChanged) to gate renderedCallback updates. |
| Accessibility | Add role attributes and aria-label to injected tables and interactive elements. |
When to Use This Pattern
This approach works best for read-only rendered content -- AI responses, formatted previews, documentation display. For interactive content requiring event handlers, prefer standard LWC template rendering with for:each iteration over structured data.
The tradeoff is verbosity: every element carries its own styles. But within Shadow DOM, this is the only technique that produces consistently styled dynamic HTML across all browsers and Salesforce runtime contexts.