`<HtmlPreview>` renders live HTML/CSS demos inside an isolated iframe with viewport presets (Mobile / Tablet / Full) and a collapsible source code panel with syntax highlighting. It is globally available in all MDX files without imports. The route-bound MDX binding supplies localized preview chrome; a direct, non-route-bound component use has no locale context and uses English labels unless you pass `labels`.

## Localized Controls

When the component is rendered through the route-bound MDX binding, the active page locale supplies these seven preview-chrome strings:

| Translation key | UI value |
|-----------------|----------|
| `htmlPreview.viewport.mobile` | Mobile viewport button |
| `htmlPreview.viewport.tablet` | Tablet viewport button |
| `htmlPreview.viewport.full` | Full-width viewport button |
| `htmlPreview.viewport.label` | Viewport preset group `aria-label` |
| `htmlPreview.source.show` | Collapsed source toggle label |
| `htmlPreview.source.hide` | Expanded source toggle label |
| `htmlPreview.iframe.title` | Outer iframe title when the visible `title` is omitted; also the generated document-title fallback when neither an author `<title>` nor a nonblank `title` is supplied |

The lookup order is requested locale → configured default locale → package English table → raw key. A custom locale may therefore supply only the keys it translates.

The authored `title` prop controls the title bar. A nonblank value also supplies the generated iframe document title when no author `<title>` is present in the merged `head`; an author title remains authoritative. The technical code-panel headings (`HTML`, `CSS`, `Head`, and `JS`) are intentionally fixed; these translation keys do not localize them.

Pass a partial `labels` object to customize one preview. Omitted keys, including keys set to `undefined`, retain their route-localized values instead of erasing them:

````mdx
<HtmlPreview
  html="<p>One preview</p>"
  labels={{
    mobile: "Phone",
    preview: "Live preview",
  }}
/>
````

For a direct non-route-bound component import, omitted `labels` keys use the built-in English defaults. Pass the same partial object (or all seven keys) when that use needs another language.

## Preview Document Metadata

Every generated preview document receives a nonempty `<html lang>` value. When trusted `head` does not own an opening `<title>`, the component also adds a nonempty document `<title>`. The `lang` value is selected according to how the component is used:

| Use | Language precedence |
|-----|---------------------|
| Route-bound MDX `<HtmlPreview>` wrapper | nonblank explicit `lang` → active route locale → `en` |
| Direct low-level `HtmlPreview` import | nonblank explicit `lang` → `en` |

Blank or whitespace-only `lang` values are treated as omitted. Any nonblank language tag is accepted and serialized with attribute-context escaping; the value is metadata for the generated preview document's `<html lang>`, not the language of the outer page.

The generated document title follows this order:

1. A trusted author opening `<title>` in the merged `head` remains caller-owned and authoritative; no generated title is added.
2. A nonblank visible `title` prop supplies the generated document title.
3. The merged localized/per-call `labels.preview` value supplies the generated document title.
4. The literal `Preview` value is used as the final fallback.

Blank or whitespace-only `title` and `labels.preview` values fall through to the next choice. The public API intentionally has no separate `documentTitle` prop: use `head` when the author needs to own the document's `<title>`. Only generated metadata is context-escaped (`lang` for an HTML attribute and the generated title for HTML text); free-form `head` remains trusted caller content and is preserved as supplied.

The same metadata precedence and escaping apply to the complete eager preview and to a preview mounted after `loading="visible"` opens its visibility gate. The visible-mode server reservation contains no preview document until that mount occurs.

```mdx
<HtmlPreview
  lang="fr-CA"
  title="Visible title"
  labels={{ preview: "Localized fallback" }}
  head={'<title>Author title</title>'}
  html="<p>Language and title metadata are serialized.</p>"
/>
```

## Deferred Loading

The route-bound MDX `<HtmlPreview>` wrapper accepts `loading="eager"` or `loading="visible"`. It defaults to `"eager"`; omitting it is identical to passing `"eager"`. This is a wrapper lifecycle policy, not the native iframe `loading` attribute, and it is not forwarded to the iframe. The direct low-level `HtmlPreview` export does not accept this prop.

With the default `"eager"` mode, the server renders the complete preview subtree, including the iframe, `srcdoc`, and optional chrome. zfb still uses its normal visible island timing for client hydration.

With `"visible"`, server output contains only an inert, `aria-hidden` height reservation (the explicit positive `height`, or a 200px fallback). It contains no preview iframe or `srcdoc`, and no preview controls, inline scripts, or external-resource tags can run or load before the preview is mounted. After the reservation intersects the viewport, the wrapper mounts the complete preview once and normal operation begins.

zfb 2.14.2 mounts a skip-SSR render target immediately, regardless of its visible marker. To preserve the deferral, the wrapper's inner target applies a one-shot `IntersectionObserver` gate to its reservation and disconnects it after the first intersecting entry. If `IntersectionObserver` is unavailable, it fails open and mounts the preview immediately.

Both modes preserve the existing route-localized labels and optional `showSource` / `showViewportControls` chrome once the preview is rendered.

## Basic Usage

[Dynamic content unavailable in text export]

````mdx
<HtmlPreview
  title="Basic Box"
  html={`
    <div class="box">Hello, world!</div>
  `}
  css={`
    .box {
      padding: 24px;
      background: #3b82f6;
      color: #fff;
      border-radius: 8px;
      font-family: system-ui, sans-serif;
      text-align: center;
    }
  `}
/>
````

## Responsive Layout

Use the viewport buttons (Mobile / Tablet / Full) to see how content reflows at different widths. Try resizing with the drag handle at the bottom-right of the preview area.

[Dynamic content unavailable in text export]

## HTML Only

When no `css`, `head`, or `js` prop is provided, only the HTML source is shown.

[Dynamic content unavailable in text export]

## Default Open Code

Use `defaultOpen` to show the source code expanded by default.

[Dynamic content unavailable in text export]

## Optional Control Regions

Both optional control regions are shown by default. Set `showSource={false}` to structurally omit the source toggle, code panel, and highlighting markup. The preview iframe remains. `defaultOpen` only initializes the code panel state, so it has no effect when the source region is hidden.

Set `showViewportControls={false}` to structurally omit the Mobile / Tablet / Full preset group and buttons. The preview keeps the Full-width (`100%`) container and its horizontal drag-resize affordance. Hiding presets does not disable resizing.

The two flags may be `false` together. The iframe still renders. The title bar is rendered when viewport controls are visible or when a non-empty `title` is supplied; consequently, a supplied title keeps a title-only bar when presets are hidden, while omitting both presets and `title` removes the otherwise-empty bar.

## Fixed Height

Use the `height` prop to set a fixed iframe height instead of auto-sizing. The iframe keeps that exact pixel height and the auto-height controller does not install a `ResizeObserver` or change it as the content grows or shrinks.

Fixed height is required for an opaque sandbox: removing `allow-same-origin` prevents the parent from reading the iframe document. There is no opaque-origin `postMessage` resizing path, so pair a stricter `sandbox` with `height`.

[Dynamic content unavailable in text export]

## Auto-height Lifecycle

When `height` is omitted and `fullHeight` is `false`, a readable same-origin preview measures its document after the iframe loads and keeps the iframe height synchronized. The default sandbox includes `allow-same-origin`, which allows the parent to read the preview document for this purpose.

The preview observes the iframe document's `body` and `html` with `ResizeObserver`, so later growth and shrink are reflected as well as geometry changes caused by delayed stylesheets, fonts, images, or scripts. Changing a viewport preset also schedules a remeasurement after the content reflows. Script-bearing previews additionally get an immediate measurement and one 300ms follow-up measurement after load.

If the iframe does not provide `ResizeObserver`, the immediate measurement and any script-driven delayed one-shot measurement are retained, but continuous tracking is unavailable. An opaque sandbox cannot be measured at all; use a fixed `height` instead.

## Full Height

Use the `fullHeight` prop to make the preview document's `html`/`body` stretch to fill the iframe — useful for layouts that rely on `height: 100%` reaching the viewport (e.g. a flex column that fills the available space).

:::warning[Pair with an explicit `height`]
`fullHeight` interacts with the auto-height mechanism: auto-height measures the iframe body and resizes the iframe to fit, but `fullHeight` makes the body's height derive *from* the iframe instead — combining the two creates a feedback loop with no stable resolution. Without a fixed `height`, `fullHeight` deliberately disables auto-height and remains observer-free at the default 200px height. The valid pattern is to pair `fullHeight` with an explicit `height`.
:::

[Dynamic content unavailable in text export]

## External Resources

You can inject external resources (CSS frameworks, webfonts, scripts) into previews. Configure globally via `zfb.config.ts` or per-component via props.

### Global Configuration

Set `htmlPreview` in `zfb.config.ts` to apply resources to all previews:

```ts title="zfb.config.ts"
export default defineConfig(
  zudoDoc({
    htmlPreview: {
      head: `<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Noto+Sans+JP&display=swap">`,
      css: `body { font-family: 'Noto Sans JP', sans-serif; }`,
      js: `console.log('preview loaded');`,
    },
  }),
);
```

### Per-Component Props

Use `head` and `js` props to add resources to individual previews. These are merged after global values.

[Dynamic content unavailable in text export]

````mdx
<HtmlPreview
  title="With JS"
  html={`
    <div id="output">Waiting...</div>
  `}
  css={`
    #output {
      padding: 16px;
      font-family: system-ui, sans-serif;
      color: #334155;
    }
  `}
  js={`
    document.getElementById('output').textContent = 'Hello from JS!';
  `}
/>
````

### External Stylesheets & Scripts

`head` and `js` are stringly-typed and always render into a visible "Head"/"JS" code block, and the preflight reset (Tailwind v4 CSS reset injected into every preview) is always applied on top — no way to skip it for a framework that ships its own. `externalStyles`, `externalScripts`, `preflight`, and `showResources` give CDN resources (a CSS framework, webfont, or script like `@tailwindcss/browser`, Bootstrap, or htmx) a structured, code-panel-aware alternative:

- **`externalStyles`** — array of stylesheet URLs, emitted as `<link rel="stylesheet" href="...">` tags. Loaded **before** the author `css`, so `css` can still override the framework.
- **`externalScripts`** — array of script URLs, emitted as `<script src="...">` tags. Flows through the same sandbox/`syncDelay` derivation as an inline `js` prop — the preview automatically gets `sandbox="allow-scripts allow-same-origin"` and the 300ms height re-sync delay.
- **`preflight`** — set to `false` to skip the injected preflight reset entirely, for a framework (like Tailwind) that ships its own base styles.
- **`showResources`** — both arrays are **excluded from the visible code panel by default** (unlike `head`/`js`); set to `true` to surface them as literal `<link>`/`<script src>` lines at the top of the "HTML" panel when it's pedagogically useful to show the reader what's loaded.

These four props are **per-usage only** — unlike `head`/`css`/`js`, they are not part of the global `htmlPreview` configuration in `zfb.config.ts`.

:::warning[External resources load client-side, not at build time]
`externalStyles`/`externalScripts` are network requests the browser makes **when the preview iframe renders**, not assets bundled at build time. The preview may briefly show unstyled or partially styled content while the resource loads, and it depends on the resource staying available at that URL.
:::

With `loading="visible"`, the iframe is not rendered before the visibility gate opens, so external-resource requests and inline `head`/`js` work wait until the preview mounts. `loading="eager"` keeps the complete server-rendered preview behavior.

A one-line Tailwind CDN demo, using `preflight={false}` since `@tailwindcss/browser` ships its own reset:

[Dynamic content unavailable in text export]

````mdx
<HtmlPreview
  title="Tailwind CDN"
  externalScripts={["https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"]}
  preflight={false}
  html={`
    <div class="flex gap-4 p-6 bg-slate-100">
      <div class="px-4 py-2 bg-blue-500 text-white rounded-lg font-sans">Tailwind</div>
      <div class="px-4 py-2 bg-emerald-500 text-white rounded-lg font-sans">via CDN</div>
    </div>
  `}
/>
````

With `showResources`, the CDN URL is shown as a literal line at the top of the "HTML" code panel instead of being hidden:

[Dynamic content unavailable in text export]

## Security & the `sandbox` prop

Previews render inside an isolated `<iframe srcdoc>`. Its `sandbox` attribute **defaults** to `allow-scripts allow-same-origin` when the preview contains scripts (a `js` prop, a `<script>` in `head`, or a non-empty `externalScripts`), and `allow-same-origin` otherwise.

:::danger[Trust assumption]
`allow-scripts` + `allow-same-origin` together **void the iframe sandbox** — scripts inside the preview share the parent page's origin and can reach the parent document. zudo-doc keeps this default because preview content is **author-trusted MDX**, and `allow-same-origin` is what powers the auto-height measurement.

If your project renders **semi-trusted or user-submitted** HTML, override it with a stricter value via the `sandbox` prop.
:::

```mdx
<!-- Maximally restrictive: no script execution, opaque origin -->
<HtmlPreview html={untrusted} sandbox="" height={400} />

<!-- Allow scripts but keep an opaque origin (script can't reach the parent) -->
<HtmlPreview html={untrusted} sandbox="allow-scripts" height={400} />
```

Removing `allow-same-origin` gives the iframe an opaque origin, which blocks the parent from reading `iframe.contentDocument` — so it **disables auto-height**. Always pair a stricter `sandbox` with a fixed `height`. The empty string `""` is honored verbatim; only omitting the prop falls back to the computed default.

## Props

The following table describes the route-bound MDX `<HtmlPreview>` wrapper. `loading` is wrapper-only; a direct import of the low-level `HtmlPreview` accepts the preview props below but does not accept `loading`.

| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `html` | string | (required) | HTML content to render inside the preview iframe |
| `loading` | `"eager" \| "visible"` | `"eager"` | Wrapper lifecycle policy. `"visible"` defers the preview subtree until viewport intersection; it is not forwarded as the native iframe `loading` attribute |
| `css` | string | `undefined` | CSS styles applied inside the preview iframe |
| `head` | string | `undefined` | Trusted raw HTML injected into `<head>` (links, meta, fonts). An opening author `<title>` remains caller-owned and takes precedence over generated metadata |
| `js` | string | `undefined` | JavaScript executed inside the preview iframe |
| `title` | string | `undefined` | Title displayed in the preview header bar and used for the generated iframe document title when no author `<title>` is present |
| `lang` | string | route locale (`en` for direct use) | Language tag for the generated preview document's `<html lang>`. Bound use applies nonblank explicit `lang` → active route locale → `en`; direct use applies nonblank explicit `lang` → `en`; blank values fall through |
| `height` | number | auto | Fixed iframe height in pixels. When omitted, height auto-adjusts to content |
| `defaultOpen` | boolean | `false` | Show the source code panel expanded by default |
| `labels` | `Partial<HtmlPreviewLabels>` | `undefined` | Per-call labels for viewport, source, and iframe controls. Missing keys retain route-locale labels (or English defaults for direct use); `preview` also supplies the generated iframe document title when higher-priority title sources are absent |
| `showSource` | boolean | `true` | Render the source toggle and code panel. `false` removes them structurally but keeps the iframe; `defaultOpen` then has no effect |
| `showViewportControls` | boolean | `true` | Render Mobile / Tablet / Full preset controls. `false` keeps Full width and the drag-resize affordance |
| `fullHeight` | boolean | `false` | Makes the preview document's `html`/`body` stretch to 100% height. Interacts with auto-height — always pair with an explicit `height` |
| `sandbox` | string | auto | iframe `sandbox` attribute. Omit for the computed default (`allow-scripts allow-same-origin` with scripts, `allow-same-origin` without). Pass a stricter value for untrusted content — but disables auto-height when `allow-same-origin` is dropped, so set `height` too |
| `externalStyles` | string[] | `undefined` | External stylesheet URLs, injected as `<link rel="stylesheet">` before `head`/`css`. Per-usage only — loads client-side at view time, not build-bundled |
| `externalScripts` | string[] | `undefined` | External script URLs, injected as `<script src>`. Flips the sandbox/`syncDelay` derivation the same as `js`. Per-usage only — loads client-side at view time, not build-bundled |
| `preflight` | boolean | `true` | Set to `false` to skip the injected preflight reset — for a framework (loaded via `externalStyles`/`externalScripts`) that ships its own |
| `showResources` | boolean | `false` | Surfaces `externalStyles`/`externalScripts` as literal lines at the top of the "HTML" code panel. Excluded from the panel by default |

## Syntax highlighting and lazy WASM

The source code panel lazily imports the public `@takazudo/zfb-md-wasm/highlight` subpath and calls its semantic highlighter for:

- `html` — for the HTML and Head panels
- `css` — for the CSS panel
- `javascript` — for the JS panel

The renderer emits safely escaped `pre.hi-root` / `hi-*` markup and shares the document-fence `--zd-syntax-*` palette. Opening the panel is the lazy boundary: the emitted JavaScript glue and WASM companion resources are not requested beforehand. A warning for an unknown language uses escaped semantic fallback markup; import, initialization, invalid-option, or current-call failures keep the JSX-escaped plain `<pre><code>` fallback. A later panel render can retry a transient import failure.

The `HTML`, `CSS`, `Head`, and `JS` code-panel headings are technical labels and intentionally remain fixed. The `htmlPreview.*` translation keys cover the surrounding controls, not these headings.

If you serve production output through custom infrastructure, preserve the generated `.mjs` and `.wasm` assets and serve them with JavaScript and `application/wasm` MIME types. Do not copy package-internal glue paths manually; the zfb build owns the resource graph.

This browser-time set is smaller than the [full language list](https://zudo-doc.takazudomodular.com/docs/components/basic-components#code-blocks-supported-languages) available to standard build-time code blocks.

## Notes

- The preview renders inside an isolated `<iframe>` with a CSS reset (Tailwind v4 preflight), so styles do not leak in or out.
- `loading="visible"` keeps an inert height reservation until the wrapper's one-shot visibility gate opens; it does not add a native iframe lazy-loading attribute.
- Route-bound MDX previews use the active locale for the seven `htmlPreview.*` control labels; direct non-route-bound component use defaults to English unless `labels` is supplied.
- `showSource={false}` removes the source toggle and highlighting subtree structurally without removing the iframe. `defaultOpen` does not reopen a hidden source region.
- `showViewportControls={false}` removes preset buttons but keeps the Full-width container and horizontal drag-resize affordance. Both flags can be false; a title-only bar remains when a non-empty `title` is supplied, and an empty bar is omitted.
- Previews default to `sandbox="allow-same-origin"` (or `sandbox="allow-scripts allow-same-origin"` when a `js` prop or `<script>` is present) so the iframe height auto-syncs via `contentDocument`. The `srcdoc` content is author-controlled MDX. Override with the `sandbox` prop for untrusted content — see [Security & the `sandbox` prop](https://zudo-doc.takazudomodular.com/docs/components/html-preview#security-the-sandbox-prop) above.
- Omitted `height` enables same-origin auto-height when `fullHeight` is false: later body geometry changes and viewport reflows are observed. A fixed `height` is exact and observer-free; an opaque sandbox needs one because no `postMessage` resizing is used.
- Global resources from the `htmlPreview` config field are injected before per-component props.
- The `html`, `css`, and `js` props support template literals with indentation. Leading whitespace is automatically stripped (dedented) in the source code display.
- Client-side hydration is handled automatically by the component wrapper — no `client:load` directive needed in MDX.
- `externalStyles`/`externalScripts` load client-side at view time — they are network requests the browser makes when the preview iframe renders, not assets bundled at build time. See [External Stylesheets & Scripts](https://zudo-doc.takazudomodular.com/docs/components/html-preview#external-resources-external-stylesheets-scripts) above.
- Injection order in the srcdoc: preflight reset (unless `preflight={false}`) → `fullHeight` style → `externalStyles` → `externalScripts` → `head` → `css`.