Preview Frame
<zn-preview-frame> | ZnPreviewFrame
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>
watch defaults to form[data-auto-save], so only forms explicitly opted in via a
data-auto-save attribute are watched — unmarked forms keep normal submit behavior and are
never intercepted. Override watch with your own selector to widen or change the scope — the
examples here point it at a form that doesn’t exist. Watched forms are auto-saved via a POST to their
action on change, which needs a real endpoint, so it isn’t demonstrated here.
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>
A same-origin embed doesn’t need to report anything — its document is measured directly.
The measurement only ever grows the frame: the frame’s own height feeds back into it, so a value at or
under the current height is ignored rather than flipping the frame between two sizes forever. An embed
that shrinks has to report its height to be followed back down, and one whose root is sized to the
viewport (html {height: 100%}) can’t be measured at all — it reports or it clips.
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.