@creaditor/form-builder
v0.2.12
Published
An embeddable form editor (web component + React) and the JSON-driven renderer for the forms it authors.
Readme
@creaditor/form-builder
An embeddable, JSON-driven form/popup builder. Two independent halves that share one schema:
- Editor — a controlled UI (
<FormEditor>for React,<creaditor-form-builder>for anything else) that lets a person author a form and hands you the JSON. Authoring happens on the rendered form itself: click a field to select it, type its label in place, drag it into a new slot. - Renderer — turns that JSON into a working popup on your page (trigger, frequency, submit).
The editor owns no persistence, navigation, or backend. You give it a form and listen for changes; you decide what saving, publishing, fields, and mailing lists mean. That contract is the whole point — see Events & host integration.
author ──▶ <FormEditor> ──(onChange / events)──▶ your app ──▶ save / publish
│
PopupModal JSON
│
your page ◀── mountPopup(json) ◀─────────────────────────┘Install
@creaditor/form-builder is published to npm. Two ways in, depending on who's integrating:
install it into a bundler app (A), or load a self-contained bundle over a public CDN with
no npm/build at all (B).
A. In a React / bundler app — from npm
npm i @creaditor/form-builder
# pin a version: npm i @creaditor/[email protected]react / react-dom are peer dependencies (the host app provides them). Everything else comes down with the package, including @creaditor/gallery — the media-library element behind onGalleryManager. There is nothing to install or register for it, and the CDN editor bundle inlines it too, so neither path needs a second script tag.
Entry points:
| Import | What you get |
| --- | --- |
| @creaditor/form-builder | Renderer + schema (mountPopup, PopupContent, types, validatePopup, makePopup) |
| @creaditor/form-builder/editor | React FormEditor + FormEditorProps, Lang |
| @creaditor/form-builder/editor.css | The editor stylesheet (import once, React only) |
| @creaditor/form-builder/element | The <creaditor-form-builder> web component (side-effect import registers it) |
| @creaditor/form-builder/templates | Optional starter PRESETS to seed the editor's form |
Installing from a local path? Add
resolve: { dedupe: ['react', 'react-dom'] }to the consuming app's Vite config, or a symlinked package resolves its own React copy and hooks break.
B. On a plain page — over a public CDN (no npm, no build)
The published package ships two self-contained bundles (React + everything, one file each)
under dist-cdn/. Because they're on npm, any page can load them straight from a public CDN —
unpkg or jsDelivr — with a single <script>
tag. No bundler, no import map, no React on the page. This is the path for a plain HTML page,
a CMS, or an ASP.NET / ASPX site.
| Bundle | CDN URL (pin the @version) | Global |
| --- | --- | --- |
| Editor | https://unpkg.com/@creaditor/[email protected]/dist-cdn/creaditor-form-builder.js | <creaditor-form-builder> element |
| Renderer | https://unpkg.com/@creaditor/[email protected]/dist-cdn/creaditor-renderer.js | window.CreaditorPopup |
jsDelivr serves the same files at https://cdn.jsdelivr.net/npm/@creaditor/[email protected]/dist-cdn/<file>.
Editor — the authoring UI (drop into an admin page):
<script src="https://unpkg.com/@creaditor/[email protected]/dist-cdn/creaditor-form-builder.js"></script>
<creaditor-form-builder id="fb"></creaditor-form-builder>
<script>
const fb = document.getElementById('fb');
fb.form = { /* a PopupModal JSON — e.g. what your app produced earlier */ };
fb.addEventListener('change', (e) => save(e.detail)); // edited form
fb.addEventListener('publish', (e) => publish(e.detail));
</script>Renderer — show the finished popup on a public page:
<script src="https://unpkg.com/@creaditor/[email protected]/dist-cdn/creaditor-renderer.js"></script>
<script>
CreaditorPopup.mountPopup(/* the PopupModal JSON */); // wires trigger, frequency, submit
</script>The editor registers <creaditor-form-builder> on load and renders into a shadow root — React
and every style live inside the file, so nothing leaks in from (or out to) the host page.
Configure via attributes (lang, theme, accent, accent-gradient, brand-primary, show-endpoint, embed-snippet, modal) or properties; the
events table below applies unchanged. The renderer injects its own
styles on first render — no CSS import needed.
Pin the version (
@0.2.12) in production. The unversioned URL followslatestand can change under you. Prefer to self-host? The same two files are in the package underdist-cdn/— put them on your own CDN and swap the URL.
Getting the JSON in: it's assigned inline via
fb.form(editor) or passed tomountPopup(renderer). To load a form by id from your backend, fetch it in the page first.
On an ASP.NET (ASPX / Web Forms / MVC) site? Full step-by-step guide — injecting JSON from code-behind, posting edits back to a handler, rendering popups on public pages, and the
UpdatePanel/postback gotchas:docs/ASPX.md.
Quick start
React
import { useState } from 'react';
import { FormEditor } from '@creaditor/form-builder/editor';
import { makePopup, type PopupModal } from '@creaditor/form-builder';
import '@creaditor/form-builder/editor.css';
export function Studio() {
const [form, setForm] = useState<PopupModal>(() => makePopup('Newsletter popup'));
return (
<FormEditor
form={form}
onChange={setForm} // fires on every edit — persist this
onPublish={(f) => publish(f)} // Publish button in the preview toolbar
/>
);
}form is controlled: pass a new object identity to load a different form. Edits come back through onChange (and the granular events below) — the editor never mutates your prop.
Anywhere else (web component)
<script type="module">
import '@creaditor/form-builder/element';
import { makePopup } from '@creaditor/form-builder';
const el = document.createElement('creaditor-form-builder');
el.form = makePopup('Newsletter popup');
el.addEventListener('change', (e) => save(e.detail)); // edited form
el.addEventListener('publish', (e) => publish(e.detail));
document.body.appendChild(el);
</script>The bundle also exposes CreaditorFormBuilder.validatePopup(form) and .isValidPopup(form) on the same global, so a page with no build step can check a form before it stores it — see Locking the submit endpoint for why that matters when you hide the endpoint fields.
Renders into a shadow root, so the host page's CSS neither leaks in nor is polluted. String config (lang, theme, accent, accent-gradient, brand-primary, show-endpoint, embed-snippet, modal) works as attributes or properties; objects/functions (form, customFields, …) are properties. el.getForm() reads the latest form, including in-editor edits, at any time.
This ESM import expects a bundler (React resolved from
node_modules). For a plain page with no build step, use the self-contained CDN bundle instead — see B. On a plain page.
Element types
Everything an author can place on a form. The type is what lands in contentItems; the full per-type reference — which of value / options / required each one honours — is §5 of the schema.
Inputs — these produce a submission key, and are the items the field events report:
| type | Renders as | Submits |
| --- | --- | --- |
| email | an email input | the address, format-validated |
| tel | a phone input | the number as typed, leniently validated |
| date | a native date picker | YYYY-MM-DD |
| number | a native numeric input, optionally bounded by min / max | the digits as a string; empty when untouched |
| free-text-input | a single-line text input | the typed string |
| textarea | a multi-line box (rows, default 4) | the typed string, newlines kept |
| select | a dropdown | the chosen option's value |
| radio | a radio group, all options visible | the chosen option's value |
| multi-select | a checkbox list | every chosen value — a JSON array in a POST body, comma-joined in a query string |
| checkbox | a single tick box | true / false |
| toggle | a switch | true / false |
| hidden | nothing | a fixed value the author sets — never shown |
checkbox and toggle are the same boolean by two drawings: a checkbox for an agreement ("I accept the terms"), a toggle for a setting ("Email me deals"). Any input can be marked private — rendered nowhere, its value seeded from the page URL's query string, which is how a ref or campaign code rides through the form.
Layout & content — no submission key, no field events:
| type | Renders as |
| --- | --- |
| heading | a heading |
| text | a paragraph |
| spacer | a fixed vertical gap (height, default 16px) |
| html | the author's own markup, sanitized |
| page-break | nothing — it marks where a step form's next screen starts |
| submit-button | the button that fires the submit |
Hosts supplying their own fields use CustomFieldType, which is the same set minus the layout pieces and with text in place of free-text-input — see Custom fields.
Events & host integration
The editor surfaces everything a host needs to react to as callbacks (React) / DOM events (web component). Full reference with payload shapes and end-to-end examples: docs/EMBEDDING.md. At a glance:
| Concern | React prop | Web-component event | Fires when |
| --- | --- | --- | --- |
| Any edit | onChange(form) | change | the form changes at all |
| Publish | onPublish(form) | publish | the author clicks Publish |
| Close | onClose() | close | a modal editor is dismissed (its X, or the backdrop) |
| Field added | onFieldAdd(item) | fieldadd | an input/hidden field is added |
| Field removed | onFieldRemove(item) | fieldremove | a field is removed |
| Field edited | onFieldUpdate(item, previous) | fieldupdate | a field already on the form is renamed, re-optioned, made required… |
| Automation added | onAutomationAdd(a) | automationadd | a mailing-list action is added |
| Automation removed | onAutomationRemove(a) | automationremove | a mailing-list action is removed |
| Create a field | onCreateField(draft) → Promise | onCreateField (property) | the author creates a field on the fly |
| Create a mailing list | onCreateMailingList(draft) → Promise | onCreateMailingList (property) | the author creates a list on the fly |
| Create a thank-you page | onCreateThankYouPage(draft) → Promise | onCreateThankYouPage (property) | the author asks for a page to redirect to (Redirect) |
| Image search | onSearchImages(query) → Promise | onSearchImages (property) | the author searches the image picker (Image gallery) |
| Media library | onGalleryManager(emitter) | onGalleryManager (property) | once, when the full gallery opens (Media library) |
The create handlers are Promises, not events: the host persists the new field/list/page on its backend and resolves with the finalized definition (a real, validated key/id/url), and the editor shows a loader until then. The two image handlers are host integrations rather than notifications — wiring either one is what turns the Card image field into a picker at all. Everything else is a fire-and-forget notification — the resulting change is already reflected in the onChange form.
Custom fields (host-supplied inputs)
Pass the fields your backend knows about and the editor shows them under Your fields in the layout picker instead of generic inputs. Each becomes a pre-filled input whose submit key is yours.
<FormEditor
form={form}
onChange={setForm}
customFields={[
{ key: 'first_name', label: 'First name', type: 'text' },
{ key: 'phone', label: 'Phone', type: 'text', required: true },
]}
onCreateField={async (draft) => {
const key = await backend.createField(draft); // persist, get a validated key
return { ...draft, key };
}}
/>Passing customFields or onCreateField puts the editor in "integration mode". Without onCreateField, created fields fall back to a locally slugged key so the editor still works standalone.
Past a handful of fields the picker grows a search box, so a CRM-sized schema stays usable. It matches the label, the submit key, and the description — so first, first_name, and a word from the description all find the same field, and multiple terms narrow further. Searching for something that doesn't exist offers "create new field" with the query already filled in as the label.
Field rules
If your backend expects a fixed shape, say so. Flags go on the field itself, or on fieldRules for the built-in types:
<FormEditor
form={form}
onChange={setForm}
customFields={[
{
key: 'email',
label: 'Email',
type: 'email',
lockKey: true, // your key, not the author's
lockRequired: true, // always required, no toggle to switch off
lockPrivate: true, // always visible to the visitor
// max: 2, // repeatable; omitted means once per form
roleLabel: 'שלח מסר', // chip on the card: what it is to your system
recommended: true, // prompt for it, don't force it
recommendedHint: 'Without it, submissions cannot be added to SendMsg.',
},
]}
fieldRules={{ tel: { max: 1, key: 'phone', lockKey: true, creatable: false } }}
/>A host field goes on a form once by default: it submits under one key, so a second copy would collect the same answer twice. The picker greys the row out and says "Already in this form." max overrides that (a number above 1 for a genuinely repeatable field, and on a fieldRules type it's the cap where there'd otherwise be none), key fixes the submit key on new items, and lockKey hides the key row from the author entirely (it stays in the form JSON). lockRequired and lockPrivate drop the Required and Private toggles and normalize the item to match, for a field your backend can't take blank or invisible. roleLabel puts a chip on the item's card saying what the field is to your system: the text is yours, in whatever language your authors read, and it defaults to the field's label so host fields get a chip for free.
creatable: false takes a type out of the create new field form: the author can add the email field you passed in, but can't mint one of their own that submits under a key your backend has never heard of. Your own fields of that type stay addable, and with every type turned off the create row disappears entirely.
recommended is for the field your integration needs but the author still owns: while it's missing, the Layout tab shows a prompt with your recommendedHint and a one-click Add, and the picker highlights the row. Nothing is blocked — a form without it is valid and publishes normally, which keeps the same builder usable for forms that have nothing to do with your integration. pinned is the strict version: seeded into every form and undeletable.
See Field rules.
Automations (on submit)
Automations is a tab of its own in the editor, and it's always there — the author picks from three kinds, all of which run on submit and none of which the visitor sees:
| Kind | Where it lands in the JSON | Who acts on it |
| --- | --- | --- |
| Mailing list — add/remove the submitter from a list | a hidden submitTargets entry | the renderer, or your backend (see fireFromClient below) |
| Redirect — send the visitor to another page after submit | onSuccess: { type: 'redirect', … } | the renderer |
| Send email — notify an address on submit | emailAutomations[] | your backend, always |
Only the mailing-list kind needs configuring: without a usable mailingListTarget it's offered but greyed out ("Not configured for this site"), while redirect and email need nothing from the host to author. A form may carry at most one redirect.
Send email (emailAutomations)
Sending mail is inherently server-side and this package is frontend-only, so an email automation is stored as a declaration and nothing more. The author fills in a recipient and an optional subject; the builder and the renderer never send anything:
form.emailAutomations
// [{ id: 'ea_1', to: '[email protected]', subject: 'New form submission' }]If you store the form JSON, read this array when a submission arrives and send the mail yourself — otherwise every email the author configured is silently dropped. validatePopup warns on an automation with a blank to; everything else (multiple addresses, formatting, the body) is yours to define.
Redirect
A redirect is where the visitor goes, not where the data goes. It runs after the primary endpoint (
url) has already answered successfully — a failed submit showsonErrorand never reaches it. If your server is meant to receive the submission, its address belongs inurl(The submit model), not here. Putting it here alone means nothing is ever sent: the submit fails first, and the redirect you were relying on never fires.
Authored here, but stored on onSuccess — a form can only do one thing after a successful submit, so the two share a slot and the Submission section links here instead of offering a second control. forwardValues appends the submitted values to the destination URL, keyed by each field's submit key, so a thank-you page can greet the visitor by name. It puts those values in the URL, so it's the wrong switch for anything sensitive.
The destination needs its scheme, and a relative one is still allowed. poptin.com/thanks is not a site to a browser, it's a path: with no https:// it resolves against the page the form is on, so the visitor lands on https://your-site/current-dir/poptin.com/thanks — a 404 on your own domain, or an ERR_FILE_NOT_FOUND on a page opened off disk. It happens after a submission that already succeeded, so the lead is captured and the only person who sees it is the visitor. A genuinely relative destination stays legal, because a server-rendered site redirecting to ThankYou.aspx means the page next to this one, so the two are told apart by what follows the last dot of the first segment: com, co.il, tt name sites; aspx, html, php, ashx name pages. The editor adds the scheme when the author leaves the field, the renderer adds it again at the moment it navigates (so forms saved before this reach the right site without being reopened), and validatePopup raises a warning on a stored value that still names a site without one. If you write onSuccess.url yourself, or return one from onCreateThankYouPage, write it absolute and none of this applies to you.
The thank-you page
The page a redirect lands on is yours — your domain, your template, your code. The builder doesn't render or store one; it just offers the author the moment to ask for it. Pass a handler and a "create a thank-you page" button appears on the redirect automation:
<FormEditor
form={form}
onChange={setForm}
thankYouPages={pages} // what your app already has (optional)
onCreateThankYouPage={async (draft) => {
// draft: { name, formId, formName, language, successHtml? }
const page = await api.createThankYouPage(draft);
return { id: page.id, label: page.title, url: page.url }; // ThankYouPageDef
}}
/>The author names the page, you create it, and the URL you return is written into the redirect. The attachment is recorded on the form as onSuccess.thankYouPage = { id, label }, so after a reload the editor still says which page is attached — the stored label covers an editor mounted without thankYouPages, and when you do pass that list it wins, so a page renamed in your app reads correctly here.
The draft carries the form's id, name and language plus any success copy already written, so the page can be seeded rather than blank. Editing the redirect URL by hand detaches the page (typing the page's own URL back in doesn't), so the two can't disagree. Detaching only forgets the provenance: it deletes nothing in your app and leaves the URL alone. Without onCreateThankYouPage no button appears — there'd be nobody to create the page, and unlike a mailing list there's no sensible local fallback: a URL nobody hosts is a 404.
While the redirect happens the popup shows a spinner and one line ("Taking you there…", in the form's language). Navigation starts the instant the submit resolves, but the browser keeps painting the current page until the destination answers, so that screen is what the visitor watches for the length of that wait. There is deliberately no countdown: the wait is the destination page loading over the visitor's connection, which nothing here can predict, and a promised "3 seconds" that takes five reads as a broken page. newTab says "Opened in a new tab" instead — nothing is going to happen in this one.
Mailing list
Let the author say "on submit → add to Newsletter" / "remove from Trial" without touching code or endpoints. You supply the lists and one endpoint config; each choice compiles into a hidden submit target the visitor never sees.
<FormEditor
form={form}
onChange={setForm}
mailingLists={[
{ id: 'newsletter', label: 'Newsletter', description: 'Weekly product news.' },
{ id: 'promos', label: 'Promotions' },
]}
mailingListTarget={{
url: 'https://your-host.app/api/mailing',
method: 'POST',
listKey: 'list', actionKey: 'action', // all optional — sane defaults
addValue: 'subscribe', removeValue: 'unsubscribe',
}}
onCreateMailingList={async (draft) => {
const id = await backend.createList(draft); // persist, get a real id
return { ...draft, id };
}}
/>The mailing-list kind is offered for real once mailingListTarget is usable — it carries a url, or it sets fireFromClient: false — and there's at least one list or an onCreateMailingList handler. (isMailingConfigUsable(config) is exported if you need the same test.)
If you store the form JSON, set fireFromClient: false. The author's choices still compile into the form and your backend reads them back with readMailingListAutomations(form, config) when the submission arrives, but the renderer never calls them: the visitor's submit stays one request, and the mailing endpoint is never exposed to the browser. That last part matters, because a client-fired target publishes both the URL and the list / action vocabulary to anyone with devtools, who can then subscribe or unsubscribe any address they like, as often as they like. Server-side, the automation rides in behind whatever auth your submit endpoint already has.
mailingListTarget={{ fireFromClient: false }} // no url needed — nothing is dialedThe default (fireFromClient unset) fires each automation from the browser, in parallel with the primary target and best-effort, so a flaky mailing call never costs you the lead. Keep it only when you don't see the submission server-side, e.g. a CDN embed posting to a third-party form service. See docs/EMBEDDING.md for the compile model and how automations round-trip through submitTargets.
Image gallery (host-supplied search)
The card image is a URL field by default. Wire onSearchImages and the field becomes an image tile: the author types a query into the picker that opens, you return the matches, and picking a result writes its url into the form.
<FormEditor
form={form}
onChange={setForm}
onSearchImages={async (query) => {
const photos = await fetch(`/api/images?q=${encodeURIComponent(query)}`).then((r) => r.json());
return photos.map((p) => ({
id: String(p.id),
thumbUrl: p.src.tiny, // the grid tile — keep it small
url: p.src.large, // what lands in the form's imageUrl
alt: p.alt,
credit: `Photo by ${p.photographer} on Pexels`,
}));
}}
/>On the web component it's a property, not an attribute: el.onSearchImages = fn.
What the author sees. Once either image handler is wired, the Card image row stops being a text field and becomes the image itself: a tile showing the current image (or the built-in placeholder when there isn't one), labelled Add image or Change image, with a × on its corner to clear it. Clicking anywhere on the tile opens the picker. The tile is the field because the image is what an author recognizes, and a CDN URL is not. The raw URL is still reachable, one click down, under Paste a URL instead — a power-user escape hatch rather than the first thing in the panel. Wire nothing and none of this appears: the plain Image URL field and its thumbnail stay exactly as they were, next to a "gallery coming soon" chip.
You own the provider and its key. Proxy Pexels / Unsplash / your own DAM from your backend, as above, rather than calling them from the browser with a key in the bundle. Only url is committed to the form; thumbUrl, alt, and credit drive the picker UI.
Media library (folders, upload, browse — like the Creaditor editor)
onSearchImages above is a one-shot search box. If you have (or want) an actual media library behind the Card image field — folders, drag-and-drop upload, a persistent file grid — wire onGalleryManager instead. It's additive: set it alongside or instead of onSearchImages, and the image tile opens the full library UI. When both are wired, onGalleryManager wins — the tile opens the library, not the stock search, since the library can do everything the search box can. This is the same @creaditor/gallery component the Creaditor editor itself uses.
<FormEditor
form={form}
onChange={setForm}
onGalleryManager={(emitter) => {
// Called once per topic your library supports. Every handler gets
// `resolve` to hand data back, except upload, which reports progress.
emitter.on('load-folders', ({ resolve }) => {
fetch('/api/media/folders').then((r) => r.json()).then(resolve);
});
emitter.on('load-files', ({ resolve, dirId, search }) => {
const q = new URLSearchParams({ dirId: dirId ?? '', search: search ?? '' });
fetch(`/api/media/files?${q}`).then((r) => r.json()).then(resolve);
});
emitter.on('upload-file', ({ file, dirId, onProgress, onComplete, onError }) => {
const body = new FormData();
body.append('file', file);
if (dirId) body.append('dirId', dirId);
fetch('/api/media/upload', { method: 'POST', body })
.then((r) => r.json())
.then((uploaded) => {
onComplete(uploaded); // { id, name, src, size }
emitter.render(); // reload the grid so it shows up
})
.catch((err) => onError(err.message));
});
}}
/>That's the minimum to get folders + browsing + upload working. There's a full event reference — every topic, its exact payload, and what to resolve with — in Embedding & host integration → Full media library.
On the web component it's a property too: el.onGalleryManager = fn.
Rendering the result on your page
The editor's output is a PopupModal JSON (shape: POPUP-COMPONENT-JSON-SCHEMA.md). Three entry points, most to least batteries-included:
import { mountPopup, PopupMount, PopupContent } from '@creaditor/form-builder';mountPopup(json)— for a storefront/plain page. Wires the trigger, frequency cap, and placement, then renders when appropriate. Returns{ unmount() }.<PopupMount popup={json} />— the same mount behavior as a React component.<PopupContent popup={json} />— just the drawing (design + fields + submit); you decide when it shows.
const handle = mountPopup(popupJson, {
onClose: () => track('popup_dismissed'), // fired when the form closes (X, overlay, esc, auto-close)
fetchImpl: myFetch, // optional: submit through your own fetch
container: myElement, // optional: render inside this element
});
// handle.unmount() to tear downAll three are optional; onClose and fetchImpl exist on <PopupMount> too. onClose is the hook for dismiss analytics; fetchImpl swaps the fetch used for the submit (auth headers, a mock in tests). container says where to render, overriding the form's own htmlId — that's what a wrapper custom element passes (mountPopup(json, { container: this })) so the form lands inside the tag the page author placed.
The renderer injects its own styles on first render — no CSS import at the call site. See src/renderer/demo.ts for a full standalone example (npm run dev → the renderer demo at /renderer-demo.html).
Placement: inline vs. modal
placement decides where the form lands. inline is the default — the form embeds in the page flow with no overlay:
<!-- the form renders inside this element -->
<div id="signup-slot"></div>mountPopup({ ...form, placement: 'inline', htmlId: 'signup-slot' });htmlId does double duty. For both placements it's a page gate: set it and the form renders only on pages containing that element; leave it off and the form renders everywhere the script loads. For an inline form the matched element is also the anchor it embeds into. An inline form with no htmlId has nowhere to embed, so it lands at the end of <body>.
placement: 'modal' opts into the centered full-page overlay instead, where the element's position is irrelevant. Only a modal uses the mount layer — trigger timing, the frequency cap, the dismiss affordance, and the backdrop — so the builder hides those controls when the placement is inline. The one exception is the click trigger below, which means the same thing for both placements.
Opening from a button
trigger: { type: 'click' } holds the form back until someone asks for it. Two things can ask:
{
"trigger": { "type": "click" },
"launcher": { // our button — designed in the builder
"label": "Get 15% off",
"styleProps": { "backgroundColor": "#663dff", "align": "center" },
"borderRadius": 10
}
}- The launcher renders where the form would have gone — inside the
htmlIdelement, or at the end of<body>without one. Every field on it is optional: unset, it takes the submit button's fill, the card's rounding, and the renderer's own wording in the form's language, so the button matches the form it opens without the merchant designing anything. - The site's own element: put
data-creaditor-open="<form id>"on any element on the page and clicking it opens the form. The value may be left empty to mean "whichever form is mounted". The listener is on the document, so an element rendered later still works.
Both routes stay live at once — a form can ship with our button and still be opened from a menu link. A click-triggered form re-opens each time the button is pressed; the frequency cap still decides whether the button appears at all.
Card width
width + widthUnit cap the card. Each template carries the width it's designed around, and picking a template in the builder writes that width onto the form:
| Template | Width | | --- | --- | | Basic, image-behind, image-top | 500px | | Image-left, image-right | 720px | | Wide (row form) | 820px |
defaultCardWidth(design, formLayout) is exported if you need the same numbers host-side. Leave width unset and the stylesheet's own defaults apply (520px modal, 500px inline). A % unit is read against the container the form sits in.
Not to be confused with the per-item span, which sizes one content item rather than the card. The form body is a 12-column grid and each item declares the columns it takes: two fields at 6 sit side by side, three at 4 make a row of thirds, and an item wraps to the next line when it no longer fits. Unset means full width. Read it with the exported spanOf(item), which also carries older forms that authored a percentage styleProps.width. See POPUP-COMPONENT-JSON-SCHEMA.md.
The submit model
A form submits to a primary endpoint (url + method, authored in the Setup tab's Submission section, invisible to the visitor) and, optionally, to hidden submitTargets a host appends (mailing-list automations, webhooks). Field values are routed by each target's own method (GET → query, POST → JSON body). The primary target's response drives success/coupon; extra targets are fired best-effort. Details in docs/EMBEDDING.md.
url must be an address a browser can actually dial. Absolute (https://api.example.com/subscribe)
or relative to the page (AddUserFromSite.aspx, /api/subscribe) both work. A scheme on its own —
https:// — does not: fetch rejects it while parsing, before a request exists, so the browser's
network panel stays empty and there is nothing to inspect. validatePopup reports this as an error on
url, and the renderer logs the endpoint it couldn't reach; run the validator before you save a form
and you'll never meet this at runtime.
The primary endpoint is required, including when it's hidden from the author with
showEndpoint={false} — hiding the field doesn't fill it in.
After submit
What the visitor sees next is onSuccess / onError on the form. The builder authors { type: 'rich', html } for both: an HTML fragment composed in the editor's rich-text field, with inline coupon chips serialized as <span data-coupon data-code data-path data-copyable> placeholders. The renderer hydrates each placeholder into a live chip — copy button, and the code pulled from the submit response by data-path when it's set — and walks the whole fragment through a tag/attribute whitelist rather than injecting it raw, so a pasted <script> can only ever lose its markup. { type: 'close' } is the other option, and the default.
The older message, coupon, and (as a success type) redirect variants still render, so saved forms keep working; the builder migrates them into rich on first edit and authors redirects under Automations instead. TipTap ships only in the editor bundle — the renderer parses the stored HTML with DOMParser and carries no editor code.
Theming & language
These apply only to the editor chrome — the popup being edited keeps the colors and direction set in its own design section.
<FormEditor form={form} onChange={setForm}
lang="he" // 'en' (default) | 'he' (also flips the chrome to RTL)
theme="dark" // 'light' (default) | 'dark'
accent="#7c3aed"
accentGradient="linear-gradient(135deg,#7c3aed,#ec4899)"
/>On the web component: el.setAttribute('lang','he'), theme, accent, accent-gradient. In local dev, npm run dev also accepts ?lang=he&theme=dark.
Translations live in src/builder/i18n/en.ts and he.ts — one file per language, same shape (TypeScript enforces it). Add a language by adding a file and registering it in src/builder/i18n/index.tsx.
The form's own language
Separate from the chrome: language: 'en' | 'he' on the form decides the words the renderer says on its own behalf — Next, Back, Submitting…, the validation complaints, the wait on a redirect — and the direction the card lays out in. Copy the author typed is never translated; this is the language of the frame around it. An English builder can author a Hebrew form and vice versa, and the Design tab's picker is where it's chosen.
direction is still written alongside it, so a host reading direction off the form JSON keeps working. It's also the fallback: this used to be one control labelled with language names, so a form saved before the split is read as Hebrew when its direction is rtl — which is what that control meant at the time. Read either through languageOf(form) / directionOf(form) rather than off the field.
Adding a language to the renderer means a value in PopupLanguage, a direction to go with it, and a column in src/renderer/strings.ts.
Business context (brand)
Unlike the above, this one does reach the form. Pass the host business's identity and its primary color becomes the submit button's fill:
<FormEditor form={form} onChange={setForm} brand={{ primaryColor: '#663dff', logoUrl, name }} /><creaditor-form-builder brand-primary="#663dff"></creaditor-form-builder>The color is written into the form, not applied at render time, so the published JSON carries it to a storefront that has no business context of its own. Only buttons with no color of their own are filled in — an author who picked a color keeps it — and the label color is chosen for contrast (white on a dark brand color, near-black on a light one). Without brand, buttons stay the built-in black.
Because it edits the form, loading a form whose button had no color fires an onChange with the color filled in. Only primaryColor is consumed today; logoUrl and name are carried so the whole context can be passed in one object.
Locking the submit endpoint
Hosts that own the submit target (a CRM URL the developer sets, not the author) can hide the Submission "Endpoint URL" and "Method" fields:
<FormEditor form={form} onChange={setForm} showEndpoint={false} /><creaditor-form-builder show-endpoint="false"></creaditor-form-builder>The rest of the Submission section (success and error handling) stays.
Hiding the fields makes
form.urlyour job. It doesn't set, default or validate anything — submissions still go to whateverform.urlcarries, so set it on the form you pass in and on every form your app creates. A record that starts life with a placeholder ("","https://") and no field in which to correct it produces a form that cannot submit, and whose author has no way to see why.validatePopup(form)catches it before you store it.
Where a form appears (urls)
A form can limit itself to particular pages. The author pastes their addresses into the popover that follows Publish, right beside the snippet they're about to copy, and they land on the form JSON:
{ "urls": ["https://shop.co.il/pricing", "https://shop.co.il/products/*"] }Paste an address straight out of a browser. The scheme, a leading www., a trailing slash, the #fragment and case are all ignored, so https://www.shop.co.il/pricing and http://shop.co.il/pricing/ are the same page — without that, a form would silently miss every visitor who arrived at the other spelling. A star means anything (/products/*), and a bare path (/pricing) matches on any host, which keeps a form working on staging too.
A form with no urls runs everywhere, so nothing already published behaves differently.
mountPopup honours it automatically. Your own code can ask the same question without rendering anything:
import { matchesPage } from '@creaditor/form-builder';
matchesPage(form, 'https://shop.co.il/pricing'); // booleanThat's the point of it being a plain list: a backend that stores forms can answer "which forms belong on this page" with the same rules. Full reference in docs/EMBEDDING.md.
What Publish offers to copy (embedSnippet)
Publishing opens a popover with the markup the author pastes into their site. That markup is the host's: your script tag, your element, and the id your own database knows the form by. Supply it with embedSnippet.
A string is copied as-is, with {{id}} filled in from the form:
<FormEditor
form={form}
onChange={setForm}
embedSnippet={
'<script src="https://cdn.example.com/form.js"></script>\n' +
'<example-form form-id="{{id}}"></example-form>'
}
/><creaditor-form-builder embed-snippet='<example-form form-id="{{id}}"></example-form>'>
</creaditor-form-builder>A new form usually has no id yet, and publishing is what creates it. Pass a function instead, which may return a promise: the popover waits on it, so the save can mint the id the snippet has to carry.
embedSnippet={async (form) => {
const { formId } = await saveToMyDatabase(form);
return `<example-form form-id="${formId}"></example-form>`;
}}Saving there means onPublish is optional, since the Publish button appears for either one. Use both only when the id is already known by the time the snippet is asked for, or the form is saved twice. Pass null for no popover, for a host with its own publish confirmation.
Opening the editor as a dialog (modal)
By default the editor is a plain block that fills whatever box you give it. Set modal and it floats over your page instead: a dimmed backdrop with the editor centered on it, 80% of the screen in each direction on desktop, full-bleed on a small screen.
{editing && (
<FormEditor form={form} onChange={setForm} modal onClose={() => setEditing(false)} />
)}The web component's version of "mount it when the author asks", which is what modal-demo.html does:
<button id="edit">Edit form</button>
<script>
document.getElementById('edit').onclick = () => {
const fb = document.createElement('creaditor-form-builder');
fb.modal = true; // or: fb.setAttribute('modal', '')
fb.form = myFormJson;
fb.addEventListener('change', (e) => save(e.detail));
fb.addEventListener('close', () => fb.remove()); // the host's half of it
document.body.appendChild(fb);
};
</script>modal is an attribute or a property, and reads like any HTML boolean: bare modal is on, modal="false" (or "0") is off, and the property wins when both are set. Leaving it out is the default block editor, so a host that mounts the editor into its own panel changes nothing.
Dismissing it — the X on its corner, or a click on the backdrop — reports and nothing more: onClose / the close event fires, and the host decides what closing means (unmount it, or set modal off). Without a handler there is nothing to close to, so the X and the click-outside are left out; the React prop is ignored entirely when modal isn't set.
Esc is deliberately not a close: the pickers and popovers inside the editor take it for themselves, and dismissing a font browser shouldn't take the whole editor with it.
Scripts
npm run dev # the demo pages below, from source (Vite)
npm run build # build the installable library into lib/ (alias of build:lib)
npm run build:all # lib/ + both dist-cdn/ standalone bundles (what publish ships)
npm run build:cdn # → dist-cdn/creaditor-form-builder.js (editor, <creaditor-form-builder>)
npm run build:cdn:renderer # → dist-cdn/creaditor-renderer.js (renderer, window.CreaditorPopup)
npm run build:app # the dev demo site as a static build, for deploying a playground
npm run preview # serve that build locally
npm run typecheck # tsc -b --noEmitDemo pages
Four of them, and the first is the odd one out: it loads the published bundle rather than the source, so it opens by hand with no dev server, which makes it the one to hand someone.
| Page | How to open | What it shows |
| --- | --- | --- |
| demo.html | open the file directly | The editor beside a live monitor of every event it fires, with the payloads. Loads ./dist-cdn/creaditor-form-builder.js if the checkout has been through npm run build:cdn, and falls back to unpkg. |
| / | npm run dev | The editor as a host would mount it: custom fields, field rules, mailing lists, image search, all wired in src/builder/main.tsx. |
| /renderer-demo.html | npm run dev | The other half: a form rendered on a page, with its trigger, frequency cap and submit. |
| /modal-demo.html | npm run dev | A pretend host app opening the editor as a dialog, and removing it on close. |
The three dev-server pages import from src/, so they show the working tree.
Publishing to npm
The package is scoped and published public (publishConfig.access = "public"), so the
CDN URLs in Install → B resolve. What
ships is the files whitelist: lib/ (the ESM library + .d.ts types), dist-cdn/ (the two
standalone bundles), docs/, the LICENSE, and the two markdown references. docs/ is in that
list on purpose: this repo is private, so npm can't resolve the README's relative links back to
it, and the guides have to travel inside the tarball to be readable at all. prepublishOnly runs
build:all, so a publish always rebuilds every artifact first — you don't need to build by hand.
This is a closed-source distribution: the package ships compiled JS, type declarations,
and the standalone bundles — not the TypeScript source. src/ is excluded from files,
and sourcemaps are off in every build config (a .map would embed the original source).
npm login # once, as a member of the @creaditor org with publish rights
npm version patch # or minor / major — bumps package.json + tags the commit
npm publish # prepublishOnly builds lib/ + both CDN bundles, then publishes
git push --follow-tagsThen verify the CDN picked it up (replace the version):
https://unpkg.com/@creaditor/form-builder@<version>/dist-cdn/creaditor-renderer.js- Preview first:
npm publish --dry-run(ornpm pack) prints the exact file list without publishing. - Version = CDN URL. Bump the version on every change and hand consumers the new pinned URL;
the pinned
@versionis immutable on the CDN, so caches never serve them a stale file. - Closed source, but not secret. No
src/or sourcemaps ship, so your TypeScript isn't on npm. The compiledlib/*.jsanddist-cdn/*.jsare still readable (minified) JS — anyone can read a published bundle. npm has no "public package, hidden code" mode; true privacy needs a paid private registry. - License:
package.jsonsaysSEE LICENSE IN LICENSE, and theLICENSEfile ships in the package. Publishing publicly puts the code where anyone can read it; the license is what says who may use it. Keep the two in step if the terms change.
Project structure
src/
schema/ Shared source of truth: types, factories (makePopup…), validation,
and the mailing-list compile/decode helpers.
renderer/ Consumes a PopupModal JSON:
PopupContent → pure rendering (design + fields + submit)
PopupMount → mount layer (trigger, frequency, dismiss, placement)
mountPopup → vanilla loader for embedding anywhere
builder/ The authoring UI: FormEditor + child editors, host-integration
providers (customFields, mailingLists, imageSearch, brand), i18n.
templates/ The optional PRESETS starter forms (own entry point).
webcomponent/ <creaditor-form-builder> wrapper around FormEditor.
fonts.ts Google Font loading, shared by both halves.
dragSort.ts Pointer-driven reorder, shared by the canvas and the list.The schema is imported by both halves, so the renderer and editor can never drift on the data shape.
