- Theme Editor
- Dark values
- Reading and Persisting Values
- Manual saving with a submit button
- Controls
- Grouping controls with group and category
- Declaring the structure explicitly with sections
- Flat sections (no groups)
- Preview sources
- Collapsing the controls column
- Standalone panel
- Importing
- Slots
- Properties
- Events
- Custom Properties
- Parts
- Dependencies
Theme Editor
<zn-theme-editor> | ZnThemeEditor
A theme editor: slotted form controls drive a live preview frame, with a toolbar for the preview’s light/dark mode and device width.
Put form controls in the default slot and give each a name. Every control is per-mode: it holds
a light value and a dark value, and the sidebar’s mode toggle swaps which one the control displays. Changing
a control harvests every named control’s currently displayed value and pushes the active mode’s set
into the embedded preview frame as an
hp-preview:theme message — no save, no fetch, no page reload.
The controls column runs the full height of the component, with its own header row on top holding the light/dark mode toggle beside its caption. The toolbar sits opposite it, above the preview only, and switches the width the preview renders at: desktop (full width), tablet (768px) or mobile (390px). Because the iframe itself is resized, the embedded page’s own media queries fire.
Set controls-caption and preview-caption to label each column’s header row — both
are empty by default, rendering no text (the controls column’s header row still renders either way, so the
two columns stay aligned).
The preview always fills its column, leaving no dead space beneath it. min-height (default
480) is a floor for that column, not a fixed height — it’s still forwarded to the
preview frame, which uses it the same way.
Controls organize themselves into tabs with collapsible groups inside each: give each one a
group and a category and the editor builds the structure around them, no slot
names required.
<zn-theme-editor id="theme-editor-demo" src="/components/preview-frame-demo/" min-height="420" controls-caption="Theme Builder" preview-caption="Live Preview"> <zn-input group="Colors" category="Brand" name="accent" label="Accent" value="#6936f5" dark-value="#f5c542" type="color"></zn-input> <zn-input group="Colors" category="Background" name="background" label="Background" value="#ffffff" dark-value="#18181b" type="color"></zn-input> <zn-input group="Shapes" category="Radius" name="radius" label="Corner radius" type="number" value="4"></zn-input> </zn-theme-editor> <script> document.getElementById('theme-editor-demo').frameOrigin = location.origin; </script>
frame-origin must match the embed’s origin exactly — messages from any other origin are
ignored. The example sets it at runtime because the docs site is same-origin.
Dark values
Give a control a dark-value attribute alongside value to author its dark variant,
as accent and background do above. A control with no dark-value falls
back to its value in dark mode, so adding dark support to an existing editor is additive:
<zn-color-select name="accent" label="Accent" value="#6936f5" dark-value="#f5c542"></zn-color-select>
For boolean controls (zn-checkbox, zn-toggle, input[type=checkbox]),
two separate rules apply depending on whether dark-value is present:
-
Present — parsed as truthy:
dark-value="1"ordark-value="true"seeds the dark state checked, any other value seeds it unchecked. -
Absent — the dark state mirrors the control’s own light
checkedstate, whatever that is (includingtrue), the same fallback rule non-boolean controls get.
Reading and Persisting Values
Every change emits zn-theme-change with {values, mode, device}, where
values is {light, dark} — both full sets, regardless of which mode is active:
editor.addEventListener('zn-theme-change', event => { console.log(event.detail.values.light, event.detail.values.dark); });
Set action to persist automatically instead — both sets are POSTed as FormData on
a longer debounce (save-debounce, default 1000ms), with every key bracketed by
mode: light[accent], dark[accent], light[radius],
dark[radius]. mode and device are view state and are never saved.
<zn-theme-editor src="/embed?t=..." frame-origin="https://pay.example" action="/theme/save"> <zn-color-select name="accent" label="Accent" value="#6936f5" dark-value="#f5c542"></zn-color-select> </zn-theme-editor>
Saves are serialized: if changes land while a POST is in flight, exactly one further save runs afterwards with the latest values.
Manual saving with a submit button
Set submit-label to render a built-in save button at the right of the toolbar, opposite the
device controls — empty (the default) renders no button. Add manual to disable the debounced
auto-save entirely, so persistence only happens when the button is clicked; the live preview keeps updating
on every change either way, only saving becomes explicit:
<zn-theme-editor src="/embed?t=..." frame-origin="https://pay.example" manual submit-label="Save theme"> <zn-color-select name="accent" label="Accent" value="#6936f5" dark-value="#f5c542"></zn-color-select> </zn-theme-editor>
Clicking the button flushes any pending edit, then saves immediately through the same single-slot save queue
used for auto-save — it never opens a second concurrent request. With action set, a successful
save emits zn-theme-submit carrying {values} (both light and
dark sets); a failed save surfaces through the same error strip and zn-error as
auto-save. With no action, nothing is POSTed but zn-theme-submit still fires, so a
host can persist the values itself:
editor.addEventListener('zn-theme-submit', event => { console.log(event.detail.values.light, event.detail.values.dark); });
The button shows a loading state while its save is in flight and ignores further clicks until it resolves.
Controls
Any Zinc form control works. Controls must carry name as an attribute —
zn-checkbox and zn-toggle contribute their checked state as a
boolean, everything else contributes value. Disabled and unnamed controls are skipped. Every
control is per-mode with no opt-in attribute, so mode-independent values (like radius above)
end up duplicated across light and dark — accepted knowingly to keep the rule
uniform.
The value store is the theme, not a mirror of the currently visible controls: removing a control from the markup keeps its key in both value sets, and it keeps being pushed to the preview and included in saves. Clear it server-side or re-add the control under the same name to edit it again.
The footer slot holds actions beneath the controls:
<zn-theme-editor src="/embed?t=..." frame-origin="https://pay.example"> <zn-color-select name="accent" label="Accent"></zn-color-select> <zn-button slot="footer">Save</zn-button> </zn-theme-editor>
Set the controls column width with --zn-theme-editor-controls-width (default
343px, matching page-builder’s palette). Below 768px the columns stack.
Grouping controls with group and category
The simplest way to structure an editor is to let the controls describe their own place in it. Leave
sections unset and give each control a group (which becomes a tab) and a
category (a collapsible inside that tab) — the editor builds the tabs and collapsibles from
those labels and slots each control into the right one for you, so there are no slot names to keep in sync
by hand:
<zn-theme-editor src="/embed?t=..." frame-origin="https://pay.example"> <zn-color-select group="Background & Foreground" category="Colors" name="background" label="Background" value="#ffffff"></zn-color-select> <zn-color-select group="Background & Foreground" category="Colors" name="foreground" label="Foreground" value="#18181b"></zn-color-select> <zn-input group="Background & Foreground" category="Spacing" name="gap" label="Gap" type="number" value="8"></zn-input> <zn-input group="Typography" category="Family" name="font" label="Font" value="Inter"></zn-input> </zn-theme-editor>
That renders a Background & Foreground tab holding Colors and Spacing collapsibles, and a Typography tab holding Family. Labels are free text — they’re slugged into slot names internally, and two tabs can each hold a category of the same name without colliding.
Either attribute works on its own: a control with only group sits directly in its tab above any
collapsibles, and one with only category becomes its own top-level section. A control with
neither stays in the default slot, ungrouped above everything else. Tabs and collapsibles appear in the
order the controls first mention them, and controls added after mount are derived and slotted the same way.
Declaring the structure explicitly with sections
Set sections to take full control instead — it disables the attribute derivation entirely, and
is the way to set a group’s description or have it render open. It takes a JSON
array of {name, caption, groups}. Each section becomes a zn-tabs tab; each entry
in its groups — {name, caption, description?, open?} — becomes a collapsible
inside that tab, and a control is assigned to a group with slot="<group-name>":
<zn-theme-editor src="/embed?t=..." frame-origin="https://pay.example" sections='[ {"name":"colors","caption":"Colors","groups":[ {"name":"brand","caption":"Brand","open":true}, {"name":"semantic","caption":"Semantic"} ]}, {"name":"shapes","caption":"Shapes","groups":[{"name":"radius","caption":"Radius"}]} ]'> <zn-color-select slot="brand" name="accent" label="Accent" value="#6936f5"></zn-color-select> <zn-input slot="radius" name="radius" label="Corner radius" type="number" value="4"></zn-input> </zn-theme-editor>
A group with no assigned controls renders no collapsible, and a section none of whose groups are populated
renders no tab — the same “no chrome for empty config” rule flat sections already followed. Every tab’s
panel stays mounted while hidden (zn-tabs toggles visibility, never removes a panel), so
switching tabs never drops a value out of the theme, a preview push or a save.
The editor never shows a tab with everything shut: on load, and again whenever a tab is clicked, its first
collapsible expands unless one in that tab is already open. open: true therefore only matters
for picking which group opens — and closing them all yourself sticks, since nothing reopens until
the next tab click.
Flat sections (no groups)
A section can omit groups and just take controls directly via
slot="<section-name>", exactly as before nesting existed.
section-layout then decides the presentation — stacked zn-collapsibles
("collapsible", the default) or a zn-tabs strip ("tabs") — and is
otherwise ignored: once any section has a populated groups, every section renders as a
nested tab regardless of section-layout. Stacked sections get the same load-time expansion as
tabs: the first one opens unless another already has open.
<zn-theme-editor src="/embed?t=..." frame-origin="https://pay.example" section-layout="tabs" sections='[{"name":"colors","caption":"Colors"},{"name":"layout","caption":"Layout"}]'> <zn-color-select slot="colors" name="accent" label="Accent" value="#6936f5"></zn-color-select> <zn-input slot="layout" name="radius" label="Corner radius" type="number" value="4"></zn-input> </zn-theme-editor>
An author can also slot their own zn-collapsible into any named slot (or the default slot)
instead of relying on groups — its presentation is then entirely its own; add
flush yourself if you want it to run the full width of the column.
Preview sources
Set sources to a JSON array of {label, src} to render a dropdown in the toolbar,
beside the device buttons, for switching which page the preview loads:
<zn-theme-editor src="/embed?t=..." frame-origin="https://pay.example" sources='[{"label":"Checkout","src":"/embed/checkout"},{"label":"Storefront","src":"/embed/storefront"}]'> <zn-color-select name="accent" label="Accent" value="#6936f5"></zn-color-select> </zn-theme-editor>
The first entry is the initial selection — it wins over an explicit src when
sources is non-empty. Selecting a different entry reloads the iframe; nothing further is needed
to keep the theme, since the frame retains the last pushed payload and replays it once the reloaded page
re-announces itself ready. Leave sources unset (the default) and src behaves
exactly as it always has, with no dropdown rendered.
Collapsing the controls column
Set controls-collapsed to hide the controls column, or click the chevron toggle that sits on
the seam between the columns. Collapsing is purely a layout change — it never affects harvested values or
pushes a new theme to the preview. Below the 768px stacked breakpoint the toggle is hidden, since there’s no
side-by-side seam to tuck into — the editor also un-collapses itself if it’s already showing
controls-collapsed when the layout narrows that far, so the controls are never stuck
unreachable.
Standalone panel
Set standalone to present the editor as its own bordered, rounded panel — useful when it isn’t
already embedded in a page shell that provides that chrome. It also switches the preview’s
backdrop from the dot grid to a plain panel to match.
<zn-theme-editor src="/embed?t=..." frame-origin="https://pay.example" standalone> <zn-color-select name="accent" label="Accent" value="#6936f5" dark-value="#f5c542"></zn-color-select> </zn-theme-editor>
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.69/dist/components/theme-editor/theme-editor.js"></script>
To import this component from the CDN using a JavaScript import:
import 'https://cdn.jsdelivr.net/npm/@kubex/zinc@1.1.69/dist/components/theme-editor/theme-editor.js';
To import this component using a bundler:
import '@kubex/zinc/dist/components/theme-editor/theme-editor.js';
Slots
| Name | Description |
|---|---|
| (default) |
Ungrouped theme controls, rendered above any sections. Controls assigned
slot="<name>" matching a sections entry (or, when nested, a
groups entry) render inside that section/group instead. Harvesting and change detection
walk every slot’s full assigned subtree, not just direct children. With sections left
unset, the structure is instead derived from the controls’ own attributes:
group="<label>" becomes a tab and category="<label>" a
collapsible within it, and the control is slotted into that collapsible automatically. Either
attribute works alone - a control with only group sits directly in its tab, and one
with only category becomes its own top-level section. Setting
sections explicitly disables the derivation entirely.
|
toolbar
|
Actions in the toolbar, right-aligned beside the device controls. Where a save button belongs. |
footer
|
Actions pinned beneath the controls. The built-in submit button lives in the toolbar, not here. |
Learn more about using slots.
Properties
| Name | Description | Reflects | Type | Default |
|---|---|---|---|---|
src
|
URL of the preview shell page; forwarded to the frame. |
string
|
''
|
|
frameOrigin
frame-origin
|
Expected origin of the iframe; forwarded to the frame. |
string
|
''
|
|
dataUri
data-uri
|
Optional endpoint returning the base hp-preview:config payload. |
string
|
''
|
|
mode
|
Which mode the preview renders in. Travels in the theme payload. |
|
ThemeEditorMode
|
'light'
|
device
|
Preview viewport width. Resizes the frame only; not part of the payload. |
|
ThemeEditorDevice
|
'desktop'
|
minHeight
min-height
|
Minimum height of the preview row, in pixels; forwarded to the frame as its own floor. |
number
|
480
|
|
debounce
|
Debounce in ms between a control change and the push to the preview. |
number
|
150
|
|
action
|
Optional endpoint the values are POSTed to. Empty = no persistence. |
string
|
''
|
|
saveDebounce
save-debounce
|
Debounce in ms between a control change and the save POST. |
number
|
1000
|
|
sections
|
Groups controls into named sections. Empty/unset falls back to deriving the structure from the
controls’ own group/category attributes, and renders one ungrouped column
when they carry neither. A section with a non-empty groups nests a collapsible per
group inside a zn-tabs tab for that section - see groups on
ThemeEditorSection.
|
ThemeEditorSection[]
|
[]
|
|
sectionLayout
section-layout
|
Presentation for flat, group-less sections: stacked zn-collapsibles
(default) or a zn-tabs strip. Ignored once any section has groups - nested
sections always render as tabs.
|
'collapsible' | 'tabs'
|
'collapsible'
|
|
sources
|
Dropdown of preview sources, {label, src}, rendered in the toolbar. Empty/unset renders
no dropdown; the first entry wins over an explicit src when non-empty.
|
ThemeEditorSource[]
|
[]
|
|
controlsCollapsed
controls-collapsed
|
Collapses the controls column. |
|
boolean
|
false
|
standalone
|
Presents the editor as its own bordered, rounded panel with a plain preview backdrop, rather than embedded in a dotted canvas. |
|
boolean
|
false
|
controlsCaption
controls-caption
|
Caption in the controls column’s header row. Empty (default) renders no text; the row itself always renders. |
string
|
''
|
|
previewCaption
preview-caption
|
Caption at the left of the toolbar, opposite the device and mode controls. Empty (default) renders no text. |
string
|
''
|
|
submitLabel
submit-label
|
Label for the built-in submit button. Empty (default) renders no button. |
string
|
''
|
|
manual
|
Disables the debounced auto-save; saving then happens only via submit. Preview pushes are unaffected. |
boolean
|
false
|
|
values
|
The current per-mode value sets. Returns copies. |
{ light: Record
|
- | |
activeValues
|
The active mode’s values - what gets pushed to the preview frame. |
Record
|
- | |
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-theme-change |
|
Emitted when the values, mode or device change. | - |
zn-theme-submit |
|
Emitted on submit (button click), carrying the current values. With action set, only
fires after a successful save.
|
- |
zn-error |
|
Emitted when a save fails. Also seen for preview render failures: the frame’s zn-error is composed and not stopped, so it bubbles out through the editor too. | - |
Learn more about events.
Custom Properties
| Name | Description | Default |
|---|---|---|
--zn-theme-editor-controls-width |
Width of the controls column. |
Learn more about customizing CSS custom properties.
Parts
| Name | Description |
|---|---|
base |
The component’s base wrapper. |
controls |
The left-hand controls column, full height. |
controls-header |
The controls column’s header row: controls-caption on the left, the light/dark mode
toggle on the right.
|
toolbar |
The preview column’s header row: preview-caption on the left, the device switcher (and
sources/submit) on the right. Spans the preview column only.
|
section |
A rendered section’s or group’s collapsible (section-layout="collapsible", or any
nested group).
|
footer |
The footer wrapper beneath the controls. |
preview |
The preview column. |
error |
The inline error strip. |
preview__base |
The frame’s base wrapper (forwarded from zn-preview-frame). |
preview__stage |
The frame’s device-width wrapper (forwarded from zn-preview-frame). |
preview__iframe |
The frame’s iframe (forwarded from zn-preview-frame). |
preview__error |
The frame’s own error overlay (forwarded from zn-preview-frame). |
Learn more about customizing CSS parts.
Dependencies
This component automatically imports the following dependencies.
<zn-button><zn-collapsible><zn-dropdown><zn-example><zn-icon><zn-navbar><zn-opt-group><zn-option><zn-popup><zn-preview-frame><zn-select><zn-tabs><zn-tag><zn-tooltip>