ModalEngine
A declarative, multi-page form modal engine built as a native Web Component. Define your wizard flows in plain config objects — no framework required.
Installation
Drop the script into your page. No build step, no npm, no bundler needed.
Script tag
<script src="https://braveopotato.github.io/ModalEngine/modalengine.min.js"></script>
Or inline
Paste the source directly into a <script> block at the bottom of your <body>.
The script automatically calls customElements.define("procedural-modal", ModalEngine), so the element is available immediately.
Quick Start
Two objects are all you need: a config describing your modal, and an instance of RuntimeModal to launch it.
// 1. Create the runtime manager (once, globally)
const runtime = new RuntimeModal();
// 2. Register one or more modal configs
runtime.registerModals([
{
modalName: "myModal",
title: "My First Modal",
submitUrl: "/api/submit",
pages: [
{
title: "Step 1",
fields: [
{
type: "text",
name: "username",
label: "Username",
placeholder: "Enter username…"
}
]
}
]
}
]);
// 3. Open it from anywhere
runtime.openModal("myModal");
Config Object
Every modal is defined by a plain JavaScript object with the following top-level properties.
| Property | Type | Required | Description |
|---|---|---|---|
| modalName | string | ✓ | Unique identifier used to open this modal via runtime.openModal(name). |
| title | string | ✓ | Displayed in the window title bar alongside the current page title. |
| submitUrl | string | ✓* | POST endpoint. Required unless you provide a custom onSubmit handler. |
| method | string | — | HTTP method for fetch. Defaults to "POST". |
| pages | Page[] | ✓ | Ordered array of page objects. Each page becomes a wizard step. |
| onSubmit | function | — | Custom async submit handler. Overrides the built-in fetch call. |
| onSuccess | function | — | Called after a successful submission. Receives (config, formData). |
| onFailure | function | — | Called when submission fails. Receives (config, formData). |
Page Object
Each entry in pages has the following shape:
| Property | Type | Description |
|---|---|---|
| title | string | Appended to the modal header: Modal Title — Page Title. |
| fields | Field[] | Array of field descriptor objects rendered on this page. |
| onNext | async function | Optional async hook called when the user clicks Next on this page, before advancing. Receives (config, formData). Use it to validate, log, or mutate page-level data mid-flow. See Callbacks → onNext. |
Field Types
Each field object in a fields array renders a form control. The type property drives which element is created.
Renders an <input> of the given type.
Renders a resizable <textarea>. Height defaults to 60 px.
Renders a <select> dropdown. options accepts a string array, a { label: value } object, or an async callback returning either.
Renders a full-width <img>. Requires src; optional alt.
Two-column picker: a clickable item list on the left, a checkbox list on the right. Selection state is tracked per left-item and submitted as a map.
Field Properties
| Property | Type | Description |
|---|---|---|
| type | string | Field type (see above). Defaults to "text". |
| name | string | Form field name. Included in the submitted JSON payload. |
| label | string | Text shown above the input. |
| placeholder | string | Input placeholder text. |
| value | string | Default value pre-filled in the field. |
| tooltip | string | Hover tooltip shown on the label. Adds a dotted underline. |
| options | string[] | object | function | Required for select. See Dynamic Options below for all accepted shapes. |
| src | string | Image URL. Required for image type. |
| alt | string | Alt text for image type. |
| items | { id, label, options? }[] | Left-panel list entries. Required for pane. Each item may carry its own options — either a static array or an async callback — that overrides the field-level default. |
| options | varies | For select: string array, { label: value } object, or async function. For pane: { id, label }[] or async (item) => { id, label }[] — used as the default for any item that doesn't declare its own options. |
| checked | { [itemId]: id[] } | Initial checked state for pane. Keys are item ids; values are arrays of pre-checked option ids. |
| leftLabel | string | Column header for the left panel. Defaults to "Items". |
| rightLabel | string | Column header for the right panel. Defaults to "Options". |
Values entered on any page are saved when navigating forward or backward. Returning to an earlier page re-populates every field automatically.
Dynamic Options
The options property on a select field accepts three distinct shapes. The engine resolves them at render time, so options can be fetched from an API or computed on the fly.
1 — String array (original)
Each string is used as both the display label and the submitted value.
{
type: "select",
name: "tier",
label: "Plan",
options: ["Community", "Professional", "Enterprise"]
// submitted value === display label
}
2 — Object { label: value }
Keys are the human-readable labels shown in the dropdown; values are what gets submitted in the form payload. Use this when the display text and the submitted identifier need to differ.
{
type: "select",
name: "countryCode",
label: "Country",
options: {
"United States": "US",
"United Kingdom": "GB",
"Germany": "DE",
"Japan": "JP"
}
// user sees "United States", payload contains "US"
}
3 — Async callback
Pass an async function. All callbacks across every page are resolved once when openModal() is called — before the modal appears — and the results are reused for the entire session. The callback must return either a string array or a { label: value } object.
{
type: "select",
name: "assignee",
label: "Assign To",
options: async () => {
const res = await fetch("/api/users");
const data = await res.json();
// Return array → label === value
return data.map(u => u.name);
// — or return object → { "Alice (admin)": "user_1", … }
}
}
All options callbacks are resolved together when openModal() is called, before the modal appears. Navigating between pages never re-fires a callback — the results are cached for the lifetime of that modal session.
This demo uses an async callback that simulates a 400 ms API fetch before populating the dropdown.
Pane Field
The pane field type renders a split-panel picker inside the modal body. The left panel lists selectable items; clicking one populates the right panel with checkboxes whose state is tracked independently per item.
Each left-panel item can declare its own options, so different items can expose completely different — or partially overlapping — right-panel choices. A field-level options serves as the default for items that don't specify their own.
Both field.options and item.options accept either a static { id, label }[] array or an async (item) => { id, label }[] callback. Callbacks receive the clicked item object as their argument and are called lazily — only when that item is first selected — and the result is cached so it is never fetched twice in the same modal session.
On submission, the payload contains a JSON-serialised object mapping every item id to the array of checked option ids — including items the user never clicked (they receive an empty array).
Config shape
{
type: "pane",
name: "hostAccess",
label: "Host Access",
leftLabel: "Hosts",
rightLabel: "Users",
options: [ // field-level default options
{ id: "alice", label: "Alice Chen" },
{ id: "bob", label: "Bob Martinez" }
],
items: [
// Uses field-level options (Alice + Bob are available)
{ id: "web-01", label: "web-01.prod" },
// Overrides with its own options — only Carol + David are available here
{
id: "db-01", label: "db-01.prod",
options: [
{ id: "carol", label: "Carol Kim" },
{ id: "david", label: "David Osei" }
]
}
],
checked: {
"web-01": ["alice"],
"db-01": ["carol"]
}
}
Callback options
Pass an async function anywhere options is accepted. It receives the item that was clicked and must return a { id, label }[] array.
{
type: "pane",
name: "hostAccess",
leftLabel: "Hosts", rightLabel: "Users",
// Returns a plain array — no pre-checked state.
options: async (item) => {
const res = await fetch(`/api/users?hostId=${item.id}`);
return (await res.json()).map(u => ({ id: u.id, label: u.name }));
},
items: [
{ id: "web-01", label: "web-01.prod" }, // uses field-level callback
{
id: "db-01", label: "db-01.prod",
// Returns { options, checked } — engine seeds the checked state automatically.
options: async (item) => {
const res = await fetch(`/api/host-access?hostId=${item.id}`);
const data = await res.json();
return {
options: data.users, // { id, label }[]
checked: data.currentAccess // string[] of pre-checked ids
};
}
}
]
}
Callbacks are invoked only when the user first clicks that item — not upfront. The resolved list is cached for the session, so switching back to a previously-visited item never triggers a second fetch. When a callback returns { options, checked }, the checked state is seeded automatically — but only if neither field.checked nor a prior user edit has already set that item's state.
Submitted payload
The name key in the form data contains a JSON string. Every item id is present — items the user never visited default to an empty array.
// formData["hostAccess"] — parsed:
{
"host-1": ["alice", "bob"],
"host-2": [],
"host-3": ["carol"]
}
Like all fields, pane state is saved when navigating between pages and fully restored when returning to that page.
Use cases
Left: hosts. Right: all users. Pre-check the users who already have SSH/sudo access to each host.
Left: groups. Right: all users. Pre-check members who already belong to each group.
Left: roles. Right: permissions. Pre-check the permissions already granted to each role.
Left: projects. Right: employees. Pre-check the people already assigned to each project.
The demo below shows a host-access manager. Each host has a different set of users pre-checked. Click a host on the left, then adjust access on the right. Submit to see the full payload.
Callbacks
onSubmit (async)
Replaces the built-in fetch call entirely. Must return a Response-like object with an ok boolean property.
onSubmit: async (config, formData) => {
const res = await myCustomApiCall(formData);
return { ok: res.success }; // must have .ok
}
onSuccess
Fires after the modal closes on a successful submission.
onSuccess: (config, formData) => {
console.log("Submitted:", formData);
showToast("Saved!");
}
onFailure
Fires when onSubmit returns { ok: false } or the built-in fetch gets a non-2xx response.
onFailure: (config, formData) => {
showErrorBanner("Submission failed. Try again.");
}
If neither onFailure nor a custom handler suppress the default behavior, the engine calls alert("Failed to submit form.") as a last-resort UX signal.
onNext (async, per-page)
An optional async hook placed on individual page objects — not on the top-level config. It fires when the user clicks Next on that page, after the current page data is saved but before the engine advances to the next page.
Signature: async (config, formData) => void
The engine awaits the promise before moving on, so you can perform any async work — API calls, logging, analytics — and the user will not see the next page until the hook resolves.
{
modalName: "onboardingFlow",
title: "Onboarding",
submitUrl: "/api/onboard",
pages: [
{
title: "Account",
fields: [
{ type: "text", name: "username", label: "Username" },
{ type: "email", name: "email", label: "Email" }
],
// Called when "Next" is clicked on this page.
// formData contains all fields collected so far.
onNext: async (config, formData) => {
await fetch("/api/analytics/step", {
method: "POST",
body: JSON.stringify({ step: "account", email: formData.email })
});
}
},
{
title: "Preferences",
fields: [
{ type: "select", name: "plan", label: "Plan",
options: ["Free", "Pro", "Enterprise"] }
]
// No onNext here — engine advances normally.
}
]
}
Common patterns
| Use case | What to do in onNext |
|---|---|
| Step analytics | Fire a fetch to a tracking endpoint with the current page's data before advancing. |
| Server-side pre-validation | POST the fields to a validation route; surface an error in the DOM if the server rejects them. |
| Draft auto-save | Persist intermediate form state to localStorage or a backend so progress isn't lost on reload. |
| Conditional routing prep | Read formData and mutate the config's subsequent pages (since getModal() returns by reference) before the user sees them. |
onNext fires after saveCurrentPageData() and before the page index increments, so formData always contains the complete, up-to-date snapshot of every field the user has touched — including those on earlier pages.
If the promise returned by onNext rejects, the engine does not swallow the error — it will propagate to the console. Wrap async work in try/catch if you need to handle failures gracefully without blocking page advancement.
RuntimeModal
RuntimeModal is a thin manager class that injects one <procedural-modal> element into the document and multiplexes any number of named configs through it.
constructor()
Creates the custom element and appends it to document.body. Call once at page load.
const runtime = new RuntimeModal();
registerModals(configs: object[])
Registers one or more config objects. Can be called multiple times — configs accumulate.
runtime.registerModals([configA, configB, configC]);
openModal(modalName: string)
Looks up the config by modalName and opens the modal. Resets the page index and clears saved form data on every open.
runtime.openModal("myModal");
Dragging
The modal header is a drag handle. Click and drag to reposition the dialog anywhere on screen. The modal is clamped to the viewport — it cannot be dragged offscreen.
Demo — Simple Form
A single-page modal with three fields and a custom submit handler that logs the payload.
Click the button below to open a live modal powered by your engine.
runtime.registerModals([{
modalName: "contactForm",
title: "Contact Us",
submitUrl: "/api/contact",
pages: [{
title: "Your Details",
fields: [
{ type: "text", name: "name", label: "Full Name" },
{ type: "email", name: "email", label: "Email" },
{ type: "textarea", name: "message", label: "Message" }
]
}],
onSubmit: async (cfg, data) => ({ ok: true }),
onSuccess: (cfg, data) => displayResult(data)
}]);
Demo — Multi-Page Wizard
A three-step installation wizard demonstrating page-to-page state persistence. Navigate back and forward — your values are preserved.
Fill in fields on each step, then navigate back to see your values restored.
{
modalName: "installWizard",
title: "Setup Wizard",
submitUrl: "/api/install",
pages: [
{
title: "License",
fields: [
{ type: "text", name: "licenseKey",
label: "License Key", tooltip: "Found in your purchase email" },
{ type: "select", name: "edition",
label: "Edition",
options: ["Community", "Professional", "Enterprise"] }
]
},
{
title: "Configuration",
fields: [
{ type: "text", name: "installDir",
label: "Install Directory",
value: "C:\\Program Files\\MyApp" },
{ type: "select", name: "language",
label: "Language",
options: ["English", "Spanish", "French", "German"] }
]
},
{
title: "Confirm",
fields: [
{ type: "textarea", name: "notes",
label: "Installation Notes (optional)" }
]
}
]
}
Demo — Custom Submit Handler
This demo simulates a failed submission to show how onFailure works, then succeeds on retry.
The first submission attempt will deliberately fail. The second will succeed.
let attempts = 0;
{
modalName: "retryDemo",
title: "Retry Demo",
submitUrl: "/api/demo",
pages: [{
title: "Submit",
fields: [
{ type: "text", name: "value", label: "Any value" }
]
}],
onSubmit: async (cfg, data) => {
attempts++;
return { ok: attempts > 1 }; // fails first time
},
onSuccess: (cfg, data) => showSuccess(data),
onFailure: (cfg, data) => showFailure()
}
Demo — Dynamic Form Mutation via onNext
Because runtime.getModal(name) returns the live config object by reference,
an onNext callback can rewrite any subsequent page's fields before the
user ever sees them. The engine renders whatever is in the config at the moment it
advances — so mutations made during the hook take effect immediately.
The demo below is a cloud deployment wizard. Page 1 asks you to pick a cloud provider.
The onNext on that page inspects your choice and rewrites the
Provider Config page fields on the fly — swapping in the correct
credential labels and placeholders for AWS, GCP, or Azure before you see them.
Select a cloud provider on the first page, then click Next to watch the following page adapt to your choice.
// Provider-specific credential fields — defined outside the config
const providerFields = {
"AWS": [
{ type: "text", name: "accessKeyId", label: "Access Key ID", placeholder: "AKIAIOSFODNN7EXAMPLE" },
{ type: "password", name: "secretAccessKey", label: "Secret Access Key", placeholder: "wJalrXUtnFEMI/K7MDENG/…" },
{ type: "select", name: "region", label: "Region",
options: ["us-east-1", "us-west-2", "eu-west-1", "ap-southeast-1"] }
],
"GCP": [ /* … */ ],
"Azure": [ /* … */ ]
};
runtime.registerModals([{
modalName: "deployWizard",
pages: [
{
title: "Target",
fields: [ /* appName + provider select */ ],
onNext: (cfg, data) => {
runtime.getModal("deployWizard").pages[1].fields = providerFields[data.provider];
}
},
{ title: "Provider Config", fields: [] },
{ title: "Confirm", fields: [ /* environment + notes */ ] }
]
}]);
getModal() returns the config by reference. Assigning a new array to pages[1].fields inside onNext mutates the live config in place. Because the engine reads the page's fields at render time — not at registration time — page 2 always reflects whatever was written during the hook.
Theming
ModalEngine uses light DOM — its internal elements live in the regular
document tree, not a Shadow DOM. This means you can style every part of the modal directly
from your page stylesheet using the procedural-modal element as a scope prefix.
/* Add to your existing <style> block — no JS, no engine changes needed */
procedural-modal .modal-header { /* title bar */ }
procedural-modal .modal-body { /* form area */ }
procedural-modal .modal-footer { /* button row */ }
procedural-modal .modal { /* outer window chrome */ }
procedural-modal .btn { /* Close / Back buttons */ }
procedural-modal .btn-primary { /* Next / Finish button */ }
procedural-modal .form-group label { /* field labels */ }
procedural-modal .form-group input,
procedural-modal .form-group select,
procedural-modal .form-group textarea { /* all input controls */ }
The five themes below show what's possible. Each is self-contained CSS you can paste straight into your stylesheet.
Theme A — Dark Terminal
procedural-modal .modal {
border-color: #2e2e2e;
}
procedural-modal .modal-header {
background: #00b86b;
color: #0a0a0a;
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
letter-spacing: 0.05em;
}
procedural-modal .modal-body,
procedural-modal .modal-footer {
background: #1a1a1a;
}
procedural-modal .form-group label {
color: #4a9e6a;
font-size: 10px;
letter-spacing: 0.06em;
text-transform: uppercase;
font-family: 'JetBrains Mono', monospace;
}
procedural-modal .form-group input,
procedural-modal .form-group select,
procedural-modal .form-group textarea {
background: #111;
color: #d0ffd0;
border-color: #2e2e2e;
font-family: 'JetBrains Mono', monospace;
}
procedural-modal .btn {
background: #222;
color: #aaa;
border-color: #444;
font-family: 'JetBrains Mono', monospace;
}
procedural-modal .btn-primary {
background: #00b86b;
border-color: #00b86b;
color: #000;
font-family: 'JetBrains Mono', monospace;
}
Theme B — Soft SaaS
procedural-modal .modal {
border-color: #e2e8f0;
border-radius: 12px;
box-shadow: 0 8px 30px rgba(99, 102, 241, 0.2);
overflow: hidden;
font-family: 'Inter', system-ui, sans-serif;
}
procedural-modal .modal-header {
background: linear-gradient(135deg, #6366f1, #4f46e5);
font-family: 'Inter', sans-serif;
}
procedural-modal .modal-body {
background: #fff;
border-bottom-color: #e2e8f0;
}
procedural-modal .modal-footer {
background: #fff;
border-top: 1px solid #e2e8f0;
}
procedural-modal .form-group label {
color: #64748b;
font-family: 'Inter', sans-serif;
}
procedural-modal .form-group input,
procedural-modal .form-group select,
procedural-modal .form-group textarea {
background: #f8fafc;
color: #0f172a;
border-color: #c7d2fe;
border-radius: 6px;
font-family: 'Inter', sans-serif;
}
procedural-modal .btn {
background: #f1f5f9;
color: #475569;
border-color: #e2e8f0;
border-radius: 6px;
font-family: 'Inter', sans-serif;
}
procedural-modal .btn-primary {
background: linear-gradient(135deg, #6366f1, #4f46e5);
border-color: #4f46e5;
border-radius: 6px;
font-family: 'Inter', sans-serif;
}
Theme C — Warm Sepia
procedural-modal .modal {
border-color: #c4a882;
font-family: 'Georgia', serif;
}
procedural-modal .modal-header {
background: linear-gradient(to bottom, #8b6a3e, #6b4f2e);
font-family: 'Georgia', serif;
letter-spacing: 0.02em;
}
procedural-modal .modal-body,
procedural-modal .modal-footer {
background: #fdf6ec;
border-color: #e8d5b7;
}
procedural-modal .form-group label {
color: #6b4f2e;
font-style: italic;
font-family: 'Georgia', serif;
}
procedural-modal .form-group input,
procedural-modal .form-group select,
procedural-modal .form-group textarea {
background: #fffaf3;
color: #3d2b1a;
border-color: #c4a882;
font-family: 'Georgia', serif;
}
procedural-modal .btn {
background: linear-gradient(to bottom, #f5e6cf, #e8d5b7);
color: #5a3e28;
border-color: #c4a882;
font-family: 'Georgia', serif;
}
procedural-modal .btn-primary {
background: linear-gradient(to bottom, #a0703a, #8b5e2e);
border-color: #7a4f24;
font-family: 'Georgia', serif;
}
Theme D — Neon Cyberpunk
procedural-modal .modal {
border-color: #ff00ff;
box-shadow: 0 0 20px rgba(255,0,255,0.4), 0 0 40px rgba(0,255,255,0.2);
}
procedural-modal .modal-header {
background: linear-gradient(135deg, #1a0033, #0d001a);
color: #ff00ff;
font-family: 'Courier New', monospace;
text-shadow: 0 0 8px #ff00ff;
letter-spacing: 0.08em;
text-transform: uppercase;
border-bottom: 1px solid #ff00ff;
}
procedural-modal .modal-body,
procedural-modal .modal-footer {
background: #0d001a;
}
procedural-modal .form-group label {
color: #00ffff;
font-family: 'Courier New', monospace;
font-size: 11px;
letter-spacing: 0.1em;
text-transform: uppercase;
text-shadow: 0 0 6px #00ffff;
}
procedural-modal .form-group input,
procedural-modal .form-group select,
procedural-modal .form-group textarea {
background: #0a0015;
color: #ff00ff;
border-color: #ff00ff;
border-radius: 0;
font-family: 'Courier New', monospace;
box-shadow: inset 0 0 6px rgba(255,0,255,0.2);
}
procedural-modal .btn {
background: transparent;
color: #00ffff;
border-color: #00ffff;
font-family: 'Courier New', monospace;
letter-spacing: 0.06em;
text-transform: uppercase;
}
procedural-modal .btn:hover {
background: rgba(0,255,255,0.1);
box-shadow: 0 0 8px rgba(0,255,255,0.4);
}
procedural-modal .btn-primary {
background: linear-gradient(135deg, #ff00ff, #aa00ff);
border-color: #ff00ff;
color: #fff;
font-family: 'Courier New', monospace;
letter-spacing: 0.06em;
box-shadow: 0 0 10px rgba(255,0,255,0.5);
}
procedural-modal .btn-primary:hover {
background: linear-gradient(135deg, #ff44ff, #cc22ff);
}
Theme E — Newspaper / Editorial
procedural-modal .modal {
border: 2px solid #111;
font-family: 'Georgia', 'Times New Roman', serif;
box-shadow: 6px 6px 0 #111;
}
procedural-modal .modal-header {
background: #111;
color: #f5f0e8;
font-family: 'Georgia', serif;
font-size: 13px;
letter-spacing: 0.04em;
text-transform: uppercase;
border-bottom: 3px double #555;
}
procedural-modal .modal-body {
background: #f5f0e8;
background-image: repeating-linear-gradient(
transparent, transparent 23px, rgba(0,0,0,0.06) 24px
);
border-bottom: 2px solid #111;
}
procedural-modal .modal-footer {
background: #f5f0e8;
border-top: 1px solid #bbb;
}
procedural-modal .form-group label {
color: #111;
font-family: 'Georgia', serif;
font-weight: bold;
font-size: 12px;
letter-spacing: 0.05em;
text-transform: uppercase;
}
procedural-modal .form-group input,
procedural-modal .form-group select,
procedural-modal .form-group textarea {
background: #fff;
color: #111;
border: 1px solid #111;
border-radius: 0;
font-family: 'Georgia', serif;
}
procedural-modal .btn {
background: #f5f0e8;
color: #111;
border: 2px solid #111;
border-radius: 0;
font-family: 'Georgia', serif;
font-size: 12px;
letter-spacing: 0.04em;
text-transform: uppercase;
box-shadow: 2px 2px 0 #111;
}
procedural-modal .btn:hover {
background: #e8e3d8;
box-shadow: 1px 1px 0 #111;
}
procedural-modal .btn-primary {
background: #111;
color: #f5f0e8;
border: 2px solid #111;
border-radius: 0;
font-family: 'Georgia', serif;
letter-spacing: 0.04em;
box-shadow: 2px 2px 0 #555;
}
procedural-modal .btn-primary:hover {
background: #333;
}