@codebards/ik-embeddable-form
v1.0.7
Published
Production-ready config-driven embeddable form platform with Shadow DOM isolation
Readme
IK Embeddable Form
A production-ready, config-driven embeddable form platform built with Preact, TypeScript, Vite, Tailwind CSS, and Shadow DOM isolation.
Table of Contents
- Architecture Overview
- Installation
- Script Usage
- Config Schema
- Environment Setup
- Build & Deployment
- Versioning Strategy
- Adding New Form Variants
- Analytics Integration
- API Integration
Architecture Overview
src/
├── core/
│ ├── FormEngine/ # Orchestrates step transitions, state, API calls
│ ├── StepResolver/ # Evaluates conditions, resolves next steps
│ ├── ConfigResolver/ # Resolves FormConfig from OpenConfig + variants
│ ├── ApiClient/ # HTTP client with retries, body/response mapping
│ └── Analytics/ # GTM · Sentry · Clarity · Cookie facades
│
├── components/
│ ├── Modal/ # Accessible modal with backdrop + progress bar
│ ├── FormSteps/ # StepRenderer · AutoStep · SuccessStep · FieldRenderer
│ └── Shared/ # Button · Input · Select · Textarea · Radio · Checkbox
│
├── configs/
│ ├── sample-webinar.config.ts # Multi-step webinar registration
│ └── sample-contact.config.ts # Conditional contact form
│
├── hooks/
│ ├── useFormEngine.ts # Subscribes to FormEngine state
│ └── useAnalytics.ts # Analytics action hooks
│
├── styles/
│ └── base.css # Tailwind directives + Shadow DOM resets
│
├── types/
│ └── index.ts # FormConfig · FormStep · ValidationRule · ApiContract · AnalyticsEvent
│
├── App.tsx # Root Preact component
└── sdk/
├── index.ts # window.IKForm global API
└── mount.ts # Shadow DOM mount/unmount/destroyShadow DOM Isolation
The form is mounted inside a Shadow DOM attached to a <div id="ik-form-host"> injected into document.body. This means:
- Global CSS on the host page cannot bleed into the form
- Form styles cannot leak out to the host page
- No class name or selector conflicts
Installation
Via CDN (Recommended)
<script src="https://cdn.example.com/forms/v1/embed.js"></script>Self-hosted
# Build for production
npm run build:production
# Upload dist/embed.js to your CDN / servernpm (for framework integration)
npm install ik-embeddable-formimport { IKForm } from 'ik-embeddable-form';
IKForm.open({ eventName: 'my-webinar', webinarType: 'live', ... });Script Usage
Basic
<script src="https://cdn.example.com/forms/embed.js"></script>
<script>
window.IKForm.open({
eventName: 'ai-summit-2026',
webinarType: 'live',
site: 'main',
variant: 'default',
});
</script>With All Options
window.IKForm.open({
// Required
eventName: 'ai-summit-2026', // Matches FormConfig.eventName
webinarType: 'live', // Matches FormConfig.webinarType
site: 'main', // Multi-site identifier (forwarded to API)
variant: 'compact', // Must match a FormVariant.id (or 'default')
// Optional
preferredSlot: 'slot-morning', // Pre-selects a slot field
prefilledValues: { // Pre-fills form fields by name
email: '[email protected]',
firstName: 'Jane',
},
configOverrides: { // Runtime overrides merged on top of config
theme: { modalMaxWidth: '600px' },
},
// Callbacks
onSuccess: (result) => {
console.log('Form submitted:', result.data);
console.log('Ticket/Reg ID:', result.data.registrationId);
},
onClose: () => {
console.log('Modal closed');
},
onError: (err) => {
console.error('Form error:', err.code, err.message);
},
});Programmatic Control
// Close the modal
window.IKForm.close();
// Full cleanup (SPA route change)
window.IKForm.destroy();
// Get loaded version
console.log(window.IKForm.getVersion()); // "1.0.0"Config Schema
FormConfig (root)
| Property | Type | Required | Description |
|------------------|-------------------|----------|--------------------------------------------------|
| id | string | ✅ | Unique config identifier |
| name | string | ✅ | Human-readable form name |
| version | string | ✅ | Semver (e.g. "1.2.0") |
| eventName | string | ✅ | Matches OpenConfig.eventName |
| webinarType | string | – | Matches OpenConfig.webinarType |
| defaultVariant | string | ✅ | Fallback variant ID |
| variants | FormVariant[] | – | Named overrides applied on top of base config |
| steps | FormStep[] | ✅ | Ordered step definitions |
| api | ApiContract | ✅ | Default submission endpoint |
| analytics | AnalyticsConfig | – | Open/close/submit event config |
| theme | ThemeConfig | – | Visual customisation |
| i18n | Record<string, string> | – | Localisation strings |
| metadata | Record<string, string \| number \| boolean> | – | Forwarded to analytics |
FormStep
| Property | Type | Description |
|------------------|----------------------------------|--------------------------------------------------|
| id | string | Unique step identifier |
| type | "form" \| "auto" \| "success" \| "error" \| "info" | Step type |
| title | string? | Step heading |
| subtitle | string? | Step sub-heading |
| hidden | boolean? | Hide from progress indicator |
| condition | ConditionalRule? | Step only reachable when condition passes |
| autoExecute | AutoExecuteConfig? | API call fired automatically on step entry |
| fields | FormField[]? | Input fields rendered in this step |
| api | ApiContract? | Per-step API call (fires on Next) |
| nextStep | string \| ConditionalNextStep[]? | Fixed or conditional next step routing |
| allowBack | boolean? | Show Back button (default: true) |
| submitLabel | string? | Override CTA label |
| analyticsEvents| AnalyticsEvent[]? | Events fired when step becomes active |
FormField
| Property | Type | Description |
|----------------|-------------------|--------------------------------------------------|
| name | string | Field name (key in form data) |
| type | FieldType | text \| email \| tel \| number \| textarea \| select \| radio \| checkbox \| date \| hidden \| slot-picker |
| label | string? | Label text |
| placeholder | string? | Placeholder text |
| defaultValue | string \| number \| boolean? | Initial value |
| options | FieldOption[]? | Options for select/radio |
| validation | ValidationRule[]? | Array of validation rules |
| condition | ConditionalRule? | Show/hide field based on other field values |
| colSpan | number? | Grid column span (1 = half, 2 = full) |
ValidationRule
| Property | Type | Description |
|--------------|------------------|---------------------------------------------------|
| type | ValidationType | required \| email \| phone \| min \| max \| minLength \| maxLength \| pattern \| custom \| async |
| value | number \| string? | Threshold or pattern string |
| message | string | Error message shown to user |
| condition | ConditionalRule? | Only apply this rule when condition is true |
ApiContract
| Property | Type | Description |
|-------------------|-----------------------|--------------------------------------------------|
| endpoint | string | Relative path or full URL. Supports {fieldName} interpolation |
| method | HttpMethod | GET \| POST \| PUT \| PATCH \| DELETE |
| bodyMapping | Record<string,string> \| "$all" | Map form fields → request body keys |
| responseMapping | Record<string,string>? | Map response JSON paths → form field names |
| queryParams | Record<string,string>? | Query params with {fieldName} interpolation|
| authenticated | boolean? | Include Authorization: Bearer header |
| retries | number? | Retry count (default: 1) |
| timeoutMs | number? | Request timeout (default: 10,000ms) |
| mockResponse | unknown? | Used in local env instead of real network call |
ConditionalRule
{
logical?: "AND" | "OR", // default: "AND"
clauses: [
{
field: "fieldName", // dot notation supported: "address.city"
operator: "equals" | "not_equals" | "contains" | "not_contains" |
"starts_with" | "ends_with" | "greater_than" | "less_than" |
"is_empty" | "is_not_empty" | "matches_regex",
value?: "some value" // not needed for is_empty / is_not_empty
}
]
}Environment Setup
| File | Used For |
|-------------------|------------------------|
| .env | Local development |
| .env.staging | Staging builds |
| .env.production | Production builds |
Required Variables
VITE_API_BASE_URL=https://api.example.com/api
VITE_GTM_ID=GTM-XXXXXXX
VITE_SENTRY_DSN=https://[email protected]/xxx
VITE_CLARITY_ID=xxxxxxxxxxBuild & Deployment
Local Dev Server
npm install
npm run dev
# Opens http://localhost:3000 with a test pageBuild Commands
npm run build # Builds with .env (local defaults)
npm run build:staging # Builds with .env.staging
npm run build:production # Builds with .env.production (minified)Build Output
dist/
├── embed.js # Self-contained IIFE bundle (reference in <script>)
└── embed.js.map # Source map (only in staging/local builds)Deployment Checklist
Run
npm run build:productionUpload
dist/embed.jsto your CDN with a versioned path:https://cdn.example.com/forms/v1.0.0/embed.jsSet cache headers:
embed.js→Cache-Control: public, max-age=31536000, immutable(versioned path)- Use a
latestalias for convenience:cdn.example.com/forms/latest/embed.jswith shorter TTL
Reference on the host page:
<script src="https://cdn.example.com/forms/v1.0.0/embed.js" async></script>
Versioning Strategy
This project follows Semantic Versioning (MAJOR.MINOR.PATCH):
| Change Type | Version Bump | Example |
|----------------------|-------------|---------|
| Breaking API change | MAJOR | 2.0.0 |
| New form variant | MINOR | 1.1.0 |
| Bug fix / patch | PATCH | 1.0.1 |
Release Process
# 1. Bump version in package.json
npm version patch # or minor / major
# 2. Build production artefact
npm run build:production
# 3. Tag the release
git tag v$(node -p "require('./package.json').version")
git push --tags
# 4. Upload dist/embed.js to CDN under versioned pathThe __VERSION__ constant is automatically injected from package.json at build time.
Adding New Form Variants
Option A — New Variant on Existing Config
Add a FormVariant to the variants array of an existing FormConfig:
// src/configs/sample-webinar.config.ts
variants: [
{
id: 'enterprise',
overrides: {
steps: [ /* override any step */ ],
theme: { modalMaxWidth: '680px' },
},
},
],Then call:
IKForm.open({ eventName: 'ai-summit-2026', webinarType: 'live', variant: 'enterprise' });Option B — New FormConfig File
- Create
src/configs/my-new-form.config.ts - Export a
FormConfigobject with a uniqueeventName+webinarType - Register it in
src/configs/index.ts:
import { myNewFormConfig } from './my-new-form.config';
export const ALL_CONFIGS: FormConfig[] = [
webinarConfig,
contactConfig,
myNewFormConfig, // ← add here
];No other files need to change. The platform supports 25+ variants this way.
Analytics Integration
GTM
Events are pushed to window.dataLayer automatically. Configure your GTM triggers on:
form_open— fired when modal opensform_close— fired when modal closesform_step_view— fired on each stepform_step_complete— fired after Next/Submit per stepform_submit_success— fired on final submissionform_submit_error— fired on submission failure
Sentry
Install @sentry/browser and uncomment the placeholder code in src/core/Analytics/sentry.ts. The DSN is injected from VITE_SENTRY_DSN.
Clarity
Ensure the Clarity script is on the host page, or uncomment the injection block in src/core/Analytics/clarity.ts.
Custom Events
Listen on the host page:
window.addEventListener('ikform:analytics', (e) => {
console.log('IKForm event:', e.detail);
});API Integration
All API calls are defined in ApiContract objects inside your form config — no hardcoded fetch calls.
Dynamic URL Interpolation
endpoint: 'slots/{slot}/reserve'
// If form data has { slot: "slot-morning" }
// → POST https://api.example.com/api/slots/slot-morning/reserveBody Mapping
bodyMapping: {
email: 'user.email', // form field → nested request body key
slot: 'booking.slotId',
}
// → { user: { email: "..." }, booking: { slotId: "..." } }
// OR forward all fields:
bodyMapping: '$all'Response Mapping
responseMapping: {
'data.registrationId': 'registrationId', // response path → form field
}
// Writes response.data.registrationId back into form state as "registrationId"Auth Token
import { apiClient } from 'ik-embeddable-form';
apiClient.setAuthToken('Bearer your-token-here');TypeScript Types Reference
import type {
FormConfig,
FormStep,
FormField,
ValidationRule,
ApiContract,
AnalyticsEvent,
OpenConfig,
ConditionalRule,
IKFormSDK,
} from 'ik-embeddable-form';License
MIT — see LICENSE
