Auto-Resize an iframe to Its Content: A Code Preview with srcdoc and postMessage
Fixing a blog's empty code preview: why I chose a sandboxed iframe over Shadow DOM, and how to fit iframe height to its content (postMessage, ResizeObserver, and the margin trap).
The preview was empty
This blog has a preview component: put HTML, CSS, and JS code in tabs inside a post, and it shows that code running. One day I noticed every preview box was empty. Opening the code, there were two causes.
Cause 1: the iframe path was never implemented
If there was a JS tab, it was supposed to render in an iframe, but that part was empty.
const renderPreview = () => {
if (props.useIframe) {
generateCombinedContent();
// iframe implementation could go here ← builds an HTML string and puts it nowhere
} else {
renderWithShadowDOM();
}
};
Cause 2: creating a Shadow DOM twice
Without a JS tab, it rendered into a Shadow DOM. The problem was that the code arrived after the first render. The first render created a Shadow DOM, and when the code arrived and it re-rendered, it called attachShadow again.
const shadow = previewHost.value.attachShadow({ mode: "open" }); // throws on the second call
attachShadow can only be called once per element. Call it twice and Chromium throws:
NotSupportedError: Failed to execute 'attachShadow' on 'Element':
Shadow root cannot be created on a host which already hosts a shadow tree.
You can reuse an existing one through element.shadowRoot. But while fixing it, a more fundamental problem showed up.
Why an iframe instead of Shadow DOM
A preview has to run post code like a standalone web page. Shadow DOM only separates styles; it still lives in the same document.
I checked it in a browser:
| Check | Shadow DOM | iframe |
|---|---|---|
| Demo JS finds elements with querySelector | No (null) | Yes |
| Demo JS can affect the blog page | Yes (same window) | No |
| Demo <dialog>, alert() | Show over the blog | Show inside the preview |
Elements inside a Shadow DOM aren't visible to the outer document's document.querySelector("#btn"). Demo JS runs in the outer document, so it gets null.
Most demo code starts with document.querySelector(...). Inside a Shadow DOM, that very first line returns null and the demo breaks. So I switched every preview to a single iframe approach.
srcdoc: a document inside an iframe, no URL needed
The simplest way to put HTML in an iframe is the srcdoc attribute. Pass an HTML string, no file or URL, and it becomes the iframe's document.
const srcdoc = computed(() => {
const close = "</" + "script>";
return `<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<style>body { margin: 0; padding: 1.5rem; display: flow-root; }</style>
<style>${props.cssCode}</style>
</head>
<body>
${props.htmlCode}
<script>${BOOTSTRAP}${close}
${props.jsCode ? `<script>${props.jsCode}${close}` : ""}
</body>
</html>`;
});
The split "</" + "script>" is deliberate. Write </script> literally inside a Vue file's <script setup>, and the Vue compiler reads it as the end of the component's script. Splitting the string avoids that.
<iframe
sandbox="allow-scripts allow-modals allow-forms"
:srcdoc="srcdoc"
:style="{ height: `${height}px` }"
></iframe>
sandbox: allow only what you need
With the sandbox attribute, almost everything inside the iframe is blocked except what you list.
| Value | Allows | Why it's here |
|---|---|---|
| allow-scripts | Running scripts | JS demos and the height-reporting script |
| allow-modals | alert(), confirm() | Common in demos |
| allow-forms | Form submission | Form validation and <dialog> close demos |
| allow-same-origin | Not included | See below |
I left out allow-same-origin on purpose. A srcdoc iframe is treated as the same origin as its parent page, and if you give it allow-scripts and allow-same-origin together, scripts inside can reach the parent page and even remove their own sandbox attribute. Together, they make the sandbox almost pointless.
Fitting the iframe height to its content
This was the real problem. An iframe doesn't grow when its content gets longer. It clips at the default 150px, or if you pick a large height, you get empty space.
The parent can't measure inside the iframe
You'd think the parent could open the iframe's document (contentDocument) and read its height. But with a sandbox and no allow-same-origin, the inside counts as a different origin. Here's what you actually get:
document.getElementById("f").contentDocument; // null
So I flipped it: the iframe measures itself and tells the parent, using postMessage, which sends messages between windows.
// script injected into the iframe (BOOTSTRAP)
const report = () =>
parent.postMessage({ codePreviewHeight: document.body.getBoundingClientRect().height }, "*");
new ResizeObserver(report).observe(document.body); // whenever content changes size
addEventListener("load", report); // after images etc. finish loading
addEventListener("resize", report); // when iframe width changes and text rewraps
ResizeObserver is the key. When a demo button adds content or a <details> opens, it reports the new height.
The parent checks the message came from its own iframe
const onMessage = (e) => {
if (e.source !== frame.value?.contentWindow) return; // ignore other iframes and ads
const h = e.data?.codePreviewHeight;
if (typeof h === "number") height.value = Math.max(80, Math.ceil(h));
};
onMounted(() => window.addEventListener("message", onMessage));
onBeforeUnmount(() => window.removeEventListener("message", onMessage));
message events arrive from every iframe on the page. A post can have several previews plus ad iframes, so without checking the sending window via e.source, one preview can pick up another's height. Checking the origin (e.origin) doesn't work here, because a sandboxed iframe's origin is "null".
Trap 1: scrollHeight never shrinks
It's tempting to measure document.documentElement.scrollHeight inside the iframe, but that value never gets smaller than the iframe's current height. Put one line of content in a 400px-tall iframe and measure:
document.documentElement.scrollHeight; // 400 (the iframe's height)
document.body.getBoundingClientRect().height; // the real content height
Measure with scrollHeight and the iframe can grow but never shrink back. So I measured the body's real size.
Trap 2: margins escape the body
Even the body measurement can come out oddly small. The first child's top margin and the last child's bottom margin escape outside the body (margin collapsing). With body { margin: 0 } and one <p> with 40px top and bottom margins, the difference is clear:
| Body style | Measured height |
|---|---|
| Default | 18px. 80px of margin is lost and content gets cut off |
| display: flow-root | 98px. Margins included |
display: flow-root makes the body its own block formatting context, so children's margins don't leak out. People used tricks like overflow: hidden or padding-top: 1px for this; now one line does it.
Other things I handled
| Problem | Handling |
|---|---|
| Submitting a form in a demo navigates the iframe to a blank page | preventDefault() on submit, except method="dialog" forms that close a <dialog> |
| The blog's CSP (Content-Security-Policy) blocks demos | A srcdoc iframe inherits the parent page's CSP. This blog keeps 'unsafe-inline' because of the demos' inline scripts |
Try it
The preview below is the component from this post. Click "Add item" and the preview grows with its content.
Click "Reset" and the height shrinks again. With scrollHeight, it wouldn't.
Summary
| Goal | How |
|---|---|
| Run code like a standalone page | iframe + srcdoc, not Shadow DOM |
| Run it safely | Add sandbox, without allow-same-origin |
| Measure height | Measure the body inside the iframe (getBoundingClientRect) |
| Report height | ResizeObserver + parent.postMessage |
| Receive messages | Check the sender with e.source |
| Escaping margins | display: flow-root on the body |
It's easy to assume "the parent can just measure it," but the moment you add a sandbox, the parent can't see inside. Switch to measuring inside and reporting by message, and you get both the sandbox and auto height.