npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@creaditor/form-builder

v0.1.10

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).

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) 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.1.7) in production. The unversioned URL follows latest and can change under you. Prefer to self-host? The same two files are in the package under dist-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 to mountPopup (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>

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) 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.


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 | | Field added | onFieldAdd(item) | fieldadd | an input/hidden field is added | | Field removed | onFieldRemove(item) | fieldremove | a field is removed | | 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 |

The two create handlers are Promises, not events: the host persists the new field/list on its backend and resolves with the finalized definition (a real, validated key/id), and the editor shows a loader until then. 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 } }}
/>

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.

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

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.

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: falseand 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 dialed

The 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 it becomes a Browse gallery picker: the author types a query, you return the matches, and picking a tile 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.

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. Without the handler the plain Image URL field stays, next to a "gallery coming soon" chip.


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
});
// handle.unmount() to tear down

Both options are optional and both 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).

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 /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.

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.

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.

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 doesn't set the URL — submissions still go to whatever form.url carries, so set it on the form you pass in.


Scripts

npm run dev                 # editor demo + renderer demo (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 --noEmit

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-tags

Then 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 (or npm 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 @version is 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 compiled lib/*.js and dist-cdn/*.js are 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.json says SEE LICENSE IN LICENSE, and the LICENSE file 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.