Skip to main content
Light Dark System

Preview Frame

<zn-preview-frame> | ZnPreviewFrame
Since 1.0 experimental

Embeds a live preview iframe and drives the hp-preview postMessage protocol: answers the frame’s ready handshake with a config payload fetched from data-uri, auto-saves watched forms on change, refreshes the preview after each save (its own, or a shell-driven save of a refresh-on form), accepts a theme payload via setTheme() that is retained and replayed after every ready handshake, and grows the frame to a content height the embed reports so the panel scrolls an overflowing page.

The frame loads the embed page from src, waits for it to post hp-preview:ready, fetches the JSON payload from data-uri and posts it back into the frame as hp-preview:config. The embed then reports hp-preview:rendered or hp-preview:error.

The example below embeds the demo embed page, which implements the embed side of the protocol. frame-origin must match the embed’s origin exactly — messages from any other origin are ignored — so the example sets it at runtime since the docs demo is same-origin.

<zn-preview-frame
  id="preview-frame-demo"
  src="/components/preview-frame-demo/"
  data-uri="/data/preview-frame-payload.json"
  watch="#preview-frame-demo-form"></zn-preview-frame>

<script>
  document.getElementById('preview-frame-demo').frameOrigin = location.origin;
</script>

The frame always fills the panel; zoom (0–1, default 1) zooms the previewed page out browser-style — e.g. zoom="0.4" renders the content at 40% size with correspondingly more of the page visible. min-height (default 480) sets the visible panel height in pixels.

Set fill to make the panel fill its container’s height instead — min-height then becomes a floor rather than the height, for hosts (like zn-theme-editor) whose layout already stretches the panel to match a taller sibling. zoom is ignored when fill is set, since its oversize maths needs a known pixel height to scale against, which fill deliberately doesn’t have.

device constrains and centres the preview to desktop (full width), tablet (768px) or mobile (390px), resizing the iframe itself so the embedded page’s media queries fire. setTheme({mode, values}) posts an hp-preview:theme message and replays it after each ready handshake, which is how zn-theme-editor drives a live preview.

The panel behind the preview is a dot grid, so the frame’s bounds stay visible instead of blending into the page — at tablet or mobile the dots fill the gutters either side of the narrowed iframe. Tune it with --zn-preview-frame-dot-spacing (default 20px) and --zn-preview-frame-dot-opacity (default 0.08). The iframe itself is given an opaque background, so the dots never show through the previewed page.

Set backdrop="panel" to swap the dot grid for a plain rgb(var(--zn-panel)) fill — used by zn-theme-editor’s standalone mode, where the frame is already inside its own bordered panel. backdrop="dots" is the default.

Interactivity

The preview is display-only: the iframe takes pointer-events: none, so clicks never reach the embedded page and the previewed form can’t be submitted or navigated away from inside the frame. The embed is cross-origin, so its own handlers can’t be cancelled from out here — blocking pointer input is the only way to stop them, and hover goes with it. Scrolling doesn’t: an overflowing page is scrolled by the panel instead, as below.

Set interactive when the embed is meant to be used rather than looked at. The frame then behaves as a viewport: it stays the panel’s own height and the embed scrolls itself, so there’s a single scrollbar and the embed’s 100vh, position: fixed and sticky content size to what’s actually on screen. The reported content height is ignored while interactive is set — it exists to make an inert frame’s overflow reachable, which an interactive one does for itself.

<zn-preview-frame
  id="preview-frame-interactive"
  src="/components/preview-frame-demo/"
  data-uri="/data/preview-frame-payload.json"
  watch="#preview-frame-interactive-none"
  interactive></zn-preview-frame>

<script>
  document.getElementById('preview-frame-interactive').frameOrigin = location.origin;
</script>

Overflowing Content

An inert page taller than the panel is scrolled by the panel, not inside the frame. The frame can’t do it itself: a cross-origin document can’t be scrolled from the host (contentWindow.scrollTo is blocked), and with pointer input off the wheel never reaches it anyway. So the frame is instead laid out at its full content height — nothing scrolls inside it — and the panel scrolls that. An interactive frame doesn’t need any of this and opts out of it: it keeps the panel’s height and the embed scrolls itself.

For the frame to be sized that way, the embed reports its height alongside hp-preview:rendered:

post({
  type: 'hp-preview:rendered',
  height: document.documentElement.scrollHeight
});

A page that grows after its first render — a revealed section, a lazy-loaded image — reports the new height on its own:

post({type: 'hp-preview:height', height: document.documentElement.scrollHeight});

Heights that aren’t a positive number are ignored, as is one reported while the error overlay is up. A height under the panel’s own is kept but changes nothing: the frame still fills the panel, so the backdrop never shows under a short page. The height is dropped whenever src changes, since the next page has its own.

The example below previews a long itemised page, so the panel scrolls. Scrolling works with the frame inert — clicking Pay still does nothing.

<zn-preview-frame
  id="preview-frame-tall"
  src="/components/preview-frame-demo/"
  data-uri="/data/preview-frame-payload-tall.json"
  watch="#preview-frame-tall-none"></zn-preview-frame>

<script>
  document.getElementById('preview-frame-tall').frameOrigin = location.origin;
</script>

Live Form Updates

In a real deployment, editing a watched form auto-saves it and the preview refreshes with the newly saved config. This docs site is static, so the example simulates the save: form changes are encoded into a data: payload URI and refresh() re-runs the fetch → hp-preview:config cycle — the same path a real save triggers.

<form id="preview-frame-live-form" class="preview-frame-live-form">
  <zn-input name="merchant" label="Merchant" value="Acme Donuts"></zn-input>
  <zn-input name="amount" label="Amount" value="£24.99"></zn-input>
  <zn-input name="buttonLabel" label="Button label" value="Pay £24.99"></zn-input>
  <label class="preview-frame-live-form__color">Accent
    <input type="color" name="accent" value="#6936f5">
  </label>
</form>

<zn-preview-frame
  id="preview-frame-live"
  src="/components/preview-frame-demo/"
  watch="#preview-frame-live-none"></zn-preview-frame>

<script>
  customElements.whenDefined('zn-preview-frame').then(() => {
    const frame = document.getElementById('preview-frame-live');
    const form = document.getElementById('preview-frame-live-form');
    frame.frameOrigin = location.origin;

    const update = () => {
      const payload = Object.fromEntries(new FormData(form));
      frame.dataUri = 'data:application/json,' + encodeURIComponent(JSON.stringify(payload));
      frame.refresh();
    };

    form.addEventListener('zn-input', update);
    form.addEventListener('input', update);
    update();
  });
</script>

<style>
  .preview-frame-live-form {
    display: flex;
    flex-wrap: wrap;
    gap: 16px;
    align-items: flex-end;
    margin-bottom: 16px;
  }

  .preview-frame-live-form zn-input {
    flex: 1;
    min-width: 140px;
  }

  .preview-frame-live-form__color {
    display: flex;
    flex-direction: column;
    gap: 4px;
    font-size: 0.875rem;
    font-weight: 500;
  }
</style>

Error Overlay

When the embed reports hp-preview:error (or fetching the payload fails), the message is shown in an overlay and zn-error is emitted. This example uses a payload that asks the demo embed to fail.

<zn-preview-frame
  id="preview-frame-demo-error"
  src="/components/preview-frame-demo/"
  data-uri="/data/preview-frame-payload-error.json"
  watch="#preview-frame-demo-error-form"></zn-preview-frame>

<script>
  document.getElementById('preview-frame-demo-error').frameOrigin = location.origin;
</script>

Importing

If you’re using the autoloader or the traditional loader, you can ignore this section. Otherwise, feel free to use any of the following snippets to cherry pick this component.

To import this component from the CDN using a script tag:

<script type="module" src="https://cdn.jsdelivr.net/npm/@kubex/zinc@1.1.132/dist/components/preview-frame/preview-frame.js"></script>

To import this component from the CDN using a JavaScript import:

import 'https://cdn.jsdelivr.net/npm/@kubex/zinc@1.1.132/dist/components/preview-frame/preview-frame.js';

To import this component using a bundler:

import '@kubex/zinc/dist/components/preview-frame/preview-frame.js';

Properties

Name Description Reflects Type Default
src URL of the preview shell page (tokened embed URL). string ''
frameOrigin
frame-origin
Expected origin of the iframe; all postMessage traffic is checked against it. string ''
dataUri
data-uri
Endpoint returning the hp-preview:config payload JSON. The console proxy rewrites this attribute to an app-prefixed path for proper fetch resolution. string ''
watch Selector (resolved against the component’s root node) for the forms to watch. Defaults to only forms explicitly opted in via a data-auto-save attribute — unmarked forms keep normal submit behavior and are never intercepted, auto-saved, or used to trigger a preview refresh. Override to widen the scope. string 'form[data-auto-save]'
refreshOn
refresh-on
Selector for forms whose saves are left to the shell but should still refresh the preview. These are never intercepted: the shell submits them (so its own response handling — alerts, refreshes — runs as normal) and the preview re-fetches its config once the shell reports the save complete. Matched by delegation on the shell’s bubbled complete event rather than by attaching to the forms themselves, so a save anywhere on the page refreshes the preview — including forms in a different DOM root, e.g. a page-level form saved while the preview sits inside a tab panel’s shadow root (a page’s Template select lives on one tab, its preview on another). Set empty to disable. string 'form'
debounce Debounce in ms between a form change and its auto-save. number 400
zoom Zooms the previewed page out (0–1]. The frame always fills the panel; zoom shrinks the content browser-style, so 0.4 shows the page at 40% size with correspondingly more of it visible. 1 = natural size. Ignored when fill is set. number 1
minHeight
min-height
The visible height (in CSS pixels) of the preview panel. Fixed rather than measured, because a measured height would feed back into the scaled iframe’s layout box and grow unbounded. With fill set, this becomes a min-height floor instead of the height. number 480
fill Fills the panel’s own column height instead of using a fixed min-height pixel height — for hosts (like zn-theme-editor) whose layout already stretches the column to match a taller sibling. zoom is ignored when set: its oversize maths depends on a known pixel height, which fill deliberately doesn’t have. boolean false
device Constrains and centres the preview to a device width: desktop (100%), tablet (768px) or mobile (390px). The iframe element itself is narrowed, so the embedded page’s own media queries fire. PreviewFrameDevice 'desktop'
backdrop Backdrop behind the stage: dots (default) is the canvas dot grid; panel is a plain rgb(var(--zn-panel)) fill. 'dots' | 'panel' 'dots'
interactive Lets pointer input through to the embedded page. The preview is inert by default: clicks never reach the frame, so the previewed page can’t be navigated or submitted from inside the preview. Cross-origin content can’t be reached from here to cancel its own handlers, so this blocks pointer input entirely — hover goes with it. Scrolling doesn’t: an overflowing page is scrolled by the panel rather than by the frame (see _contentHeight). Set, the frame becomes a real viewport instead: it stays the panel’s own height and the embed scrolls itself, so there is one scrollbar rather than a panel scrolling an oversized frame, and the embed’s viewport-relative layout (100vh, position: fixed, sticky headers) sizes to what’s on screen. _contentHeight is ignored while this is set. boolean false
updateComplete A read-only promise that resolves when the component has finished updating.

Learn more about attributes and properties.

Events

Name React Event Description Event Detail
zn-error Emitted when the preview reports a render error or a save fails. -

Learn more about events.

Methods

Name Description Arguments
refresh() Re-fetches the payload and pushes a fresh config to the preview. -
setTheme() Pushes a theme payload into the preview. The payload is retained and re-posted after every ready handshake, so a frame reload doesn’t drop an in-progress theme. theme: Record<string, unknown>

Learn more about methods.

Custom Properties

Name Description Default
--zn-preview-frame-dot-spacing Spacing of the backdrop dot grid (backdrop="dots"). Defaults to 20px.
--zn-preview-frame-dot-opacity Opacity of the backdrop dots (backdrop="dots"). Defaults to 0.08.

Learn more about customizing CSS custom properties.

Parts

Name Description
base The component’s base wrapper.
stage The device-width wrapper around the iframe.
iframe The preview iframe.
error The error overlay.

Learn more about customizing CSS parts.